fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements. UI - Pages no longer inherit the previous page's scroll position (DR-156, UR-072). The shell keeps its scrollers alive across navigation by design, so the element never remounts and its scrollTop survived the route change; SvelteKit restores window scroll, which this app never uses. ScrollMemory records the offset per route and per container: forward moves reset to the top, Back restores where the route was left. - Season header stacks on narrow screens, and the title span gets min-w-0 so it actually truncates instead of overflowing under the action buttons. - Favourites gets a labelled tile at the head of the library grid rather than only an unlabelled heart icon in the header. Playback - Full-screen video on Android hides the system bars (DR-157, UR-066). requestFullscreen() cannot touch the Activity window from inside a WebView, so the control did nothing visible while the bars stayed painted over the video. ImmersiveModeBridge hides them, restored on exit, Escape and teardown. - Background-audio handoff stops leaking its relative timeline (DR-159). background_audio_base was a display-only correction applied in two places while progress reports to Jellyfin, the frontend and media3's own seeks all worked in the relative timeline treating it as absolute — each crossing losing exactly `base` seconds. The conversion now happens once, in the position tick, and inbound seeks resolve through seek_absolute, which re-opens the stream at the requested position because the handoff transcode cannot seek. - Picture-in-picture works on the path that actually plays video (DR-160). canEnterPip demanded a native ExoPlayer surface, but that path is behind a flag defaulting to off, so PiP could never engage. It now accepts the WebView <video> too, keeping the WebView visible and routing play/pause to the element. - Native video is now the default so PiP has a real surface (DR-161). The scrub-regression tests pinned the flag-off path implicitly; they now mock it off explicitly. The native scrub/seek path is not covered by the suite and needs device verification. Watched state - Watched toggle on the episode row, season header, series and movie hero, and the Episode Focus View (DR-158, UR-073). Both backend halves already existed with no caller. storage_set_watched covers a container's episodes so the toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the missing direction. Release - Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002 under an earlier minor*1000 scheme, but the current minor*100 formula yields 1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from it was an un-installable downgrade for anyone already on v0.5.2. Widened to 10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003). - Bump to 0.5.3.
This commit is contained in:
@@ -746,6 +746,33 @@ async storageUpdatePlaybackContext(userId: string, itemId: string, positionMs: n
|
||||
async storageMarkPlayed(userId: string, itemId: string) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_mark_played", { userId, itemId });
|
||||
},
|
||||
/**
|
||||
* Set the watched flag locally for an item **and everything inside it**.
|
||||
*
|
||||
* This backs the watched toggle, and is deliberately separate from
|
||||
* [`storage_mark_played`] — which reports a single track/episode finishing and
|
||||
* increments `play_count` — because the toggle has two directions and applies
|
||||
* to containers.
|
||||
*
|
||||
* The recursion is what makes the toggle honest offline. Jellyfin applies
|
||||
* `POST`/`DELETE /PlayedItems/{id}` recursively over a season or series, so
|
||||
* online the server fixes up the children on the next read; with no server to
|
||||
* ask, marking a season watched would otherwise tick the season and leave every
|
||||
* episode inside it unwatched. Targets are drawn from `items` by the same link
|
||||
* columns the rest of the offline layer uses, so an id that is not cached
|
||||
* selects nothing and the statement is a no-op rather than a foreign-key error.
|
||||
*
|
||||
* Un-marking clears the resume position too, matching the server, so an item
|
||||
* un-marked offline does not come back offering to resume from a position it is
|
||||
* no longer meant to have.
|
||||
*
|
||||
* `pending_sync = 1` hands the rows to the sync drain.
|
||||
*
|
||||
* TRACES: UR-073 | DR-158
|
||||
*/
|
||||
async storageSetWatched(userId: string, itemId: string, watched: boolean) : Promise<null> {
|
||||
return await TAURI_INVOKE("storage_set_watched", { userId, itemId, watched });
|
||||
},
|
||||
/**
|
||||
* Get playback progress for an item
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||
import CastSection from "./CastSection.svelte";
|
||||
import GenreTags from "./GenreTags.svelte";
|
||||
import RelatedItemsSection from "./RelatedItemsSection.svelte";
|
||||
@@ -250,6 +251,12 @@
|
||||
episodeNumber={episode.indexNumber ?? undefined}
|
||||
size="lg"
|
||||
/>
|
||||
<WatchedToggleButton
|
||||
itemId={episode.id}
|
||||
watched={episode.userData?.isPlayed ?? false}
|
||||
scope="episode"
|
||||
size="lg"
|
||||
/>
|
||||
<FavoriteButton
|
||||
itemId={episode.id}
|
||||
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import VideoDownloadButton from "./VideoDownloadButton.svelte";
|
||||
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -17,9 +18,17 @@
|
||||
*/
|
||||
current?: boolean;
|
||||
onclick?: () => void;
|
||||
/** Fired when the watched toggle changes, so the series page can reload. */
|
||||
onWatchedChanged?: () => void;
|
||||
}
|
||||
|
||||
let { episode, focused = false, current = false, onclick }: Props = $props();
|
||||
let {
|
||||
episode,
|
||||
focused = false,
|
||||
current = false,
|
||||
onclick,
|
||||
onWatchedChanged,
|
||||
}: Props = $props();
|
||||
|
||||
let buttonRef: HTMLButtonElement | null = null;
|
||||
|
||||
@@ -177,6 +186,16 @@
|
||||
{duration}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Watched toggle - stop propagation to prevent episode play -->
|
||||
<div onclick={(e) => e.stopPropagation()} role="none">
|
||||
<WatchedToggleButton
|
||||
itemId={episode.id}
|
||||
watched={episode.userData?.isPlayed ?? false}
|
||||
scope="episode"
|
||||
size="sm"
|
||||
onChanged={onWatchedChanged}
|
||||
/>
|
||||
</div>
|
||||
<!-- Download button - stop propagation to prevent episode play -->
|
||||
<div onclick={(e) => e.stopPropagation()} role="none">
|
||||
<VideoDownloadButton
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import EpisodeRow from "./EpisodeRow.svelte";
|
||||
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
|
||||
import ClearHistoryButton from "./ClearHistoryButton.svelte";
|
||||
import WatchedToggleButton from "./WatchedToggleButton.svelte";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import { seasonAnchorId } from "./seriesNavigation";
|
||||
|
||||
@@ -65,18 +66,26 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Season info -->
|
||||
<!-- Season info.
|
||||
|
||||
The header stacks on narrow screens and only shares a row from `sm` up.
|
||||
Three action buttons and a season title cannot both fit across a phone,
|
||||
and side-by-side they ended up overlapping. -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<!-- The whole title block toggles the season open/closed. -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="{anchor}-episodes"
|
||||
class="flex-1 min-w-0 text-left group/season"
|
||||
class="min-w-0 sm:flex-1 text-left group/season"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<!-- min-w-0 is load-bearing: the title span below sets `truncate`, but
|
||||
a flex item will not shrink below its content width without it, so
|
||||
a long season name grew the row instead of ellipsising and ran
|
||||
under the buttons. -->
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2 min-w-0">
|
||||
<svg
|
||||
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
|
||||
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
|
||||
@@ -88,7 +97,7 @@
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="truncate">{seasonName}</span>
|
||||
<span class="truncate min-w-0">{seasonName}</span>
|
||||
{#if holdsCurrentEpisode}
|
||||
<span
|
||||
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
|
||||
@@ -120,8 +129,9 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Per-season actions -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<!-- Per-season actions. `self-start` keeps them level with the title on
|
||||
wide rows; on a stacked phone layout they sit under it. -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2 self-start">
|
||||
<SeasonDownloadButton
|
||||
seasonId={season.id}
|
||||
seriesName={season.seriesName || ""}
|
||||
@@ -130,6 +140,13 @@
|
||||
{episodeCount}
|
||||
size="sm"
|
||||
/>
|
||||
<WatchedToggleButton
|
||||
itemId={season.id}
|
||||
watched={watchedCount === episodeCount && episodeCount > 0}
|
||||
scope="season"
|
||||
size="sm"
|
||||
onChanged={onHistoryCleared}
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={season.id}
|
||||
itemName={seasonName}
|
||||
@@ -151,6 +168,7 @@
|
||||
focused={episode.id === focusedEpisodeId}
|
||||
current={episode.id === currentEpisodeId}
|
||||
onclick={() => onEpisodeClick?.(episode)}
|
||||
onWatchedChanged={onHistoryCleared}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<!--
|
||||
Mark an episode, season or series watched — or unwatched again.
|
||||
|
||||
The backend already had both halves (`mark_played` / `clear_watch_history`,
|
||||
both recursive over a container on the server) and the sync queue already
|
||||
replayed the first; nothing in the UI had ever called them, so the only way to
|
||||
mark something watched was to sit through it. This is that control.
|
||||
|
||||
Unlike ClearHistoryButton — which is the *destructive* "erase all history for
|
||||
this series", confirms, and needs the server — this is an everyday toggle: no
|
||||
confirmation, and it works offline by queueing, in both directions.
|
||||
|
||||
TRACES: UR-073 | DR-158
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { syncService } from "$lib/services/syncService";
|
||||
|
||||
interface Props {
|
||||
/** Episode, season or series id. */
|
||||
itemId: string;
|
||||
/** Current watched state, as the caller knows it. */
|
||||
watched: boolean;
|
||||
/** What is being marked, for the tooltip wording. */
|
||||
scope: "episode" | "season" | "series";
|
||||
size?: "sm" | "lg";
|
||||
/** Show a text label beside the icon rather than icon-only. */
|
||||
showLabel?: boolean;
|
||||
/** Called after a successful toggle so the caller can reload. */
|
||||
onChanged?: (watched: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
itemId,
|
||||
watched,
|
||||
scope,
|
||||
size = "lg",
|
||||
showLabel = false,
|
||||
onChanged,
|
||||
}: Props = $props();
|
||||
|
||||
let busy = $state(false);
|
||||
|
||||
// Optimistic state: the caller's `watched` prop only catches up once it has
|
||||
// reloaded from the repository, which on a season means a round trip. Without
|
||||
// this the button visibly ignores the first tap.
|
||||
let optimistic = $state<boolean | null>(null);
|
||||
const isWatched = $derived(optimistic ?? watched);
|
||||
|
||||
// A new item in the same slot (scrolling a virtualised list, switching series)
|
||||
// must drop the previous item's optimistic state or it shows the wrong tick.
|
||||
$effect(() => {
|
||||
itemId;
|
||||
optimistic = null;
|
||||
});
|
||||
|
||||
const subject = $derived(
|
||||
scope === "series" ? "series" : scope === "season" ? "season" : "episode"
|
||||
);
|
||||
const label = $derived(isWatched ? "Watched" : "Mark watched");
|
||||
const title = $derived(
|
||||
isWatched
|
||||
? `Mark this ${subject} unwatched`
|
||||
: scope === "episode"
|
||||
? "Mark this episode watched"
|
||||
: `Mark every episode in this ${subject} watched`
|
||||
);
|
||||
|
||||
async function handleClick() {
|
||||
if (busy) return;
|
||||
|
||||
const next = !isWatched;
|
||||
busy = true;
|
||||
optimistic = next;
|
||||
try {
|
||||
if (next) {
|
||||
await syncService.queueMarkPlayed(itemId);
|
||||
} else {
|
||||
await syncService.queueMarkUnplayed(itemId);
|
||||
}
|
||||
onChanged?.(next);
|
||||
} catch (e) {
|
||||
// Put the button back where it was — the change did not happen.
|
||||
optimistic = null;
|
||||
console.error("Failed to change watched state:", e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClick}
|
||||
disabled={busy}
|
||||
{title}
|
||||
aria-label={title}
|
||||
aria-pressed={isWatched}
|
||||
class="rounded-lg font-medium flex items-center gap-2 transition-colors
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
{isWatched
|
||||
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25'
|
||||
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)] hover:text-white'}
|
||||
{showLabel ? (size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm') : size === 'lg' ? 'p-2' : 'p-1.5'}"
|
||||
>
|
||||
{#if busy}
|
||||
<div
|
||||
class="border-2 border-current border-t-transparent rounded-full animate-spin
|
||||
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
|
||||
></div>
|
||||
{:else if isWatched}
|
||||
<!-- Filled check: this one is done. -->
|
||||
<svg
|
||||
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.4 14.6L6 12l1.4-1.4 3.2 3.2
|
||||
6.4-6.4L18.4 8.8l-7.8 7.8z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Outline check: available, not yet done. -->
|
||||
<svg
|
||||
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12.5l2.5 2.5L16 9.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if showLabel}
|
||||
<span>{busy ? "Saving…" : label}</span>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -23,6 +23,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
// ---- Mocks (must precede component import) --------------------------------
|
||||
|
||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||
// These tests pin the **flag-off** interim behaviour: when `experimentalNativeVideo`
|
||||
// is off, VideoPlayer overrides Android's native backend response to HTML5
|
||||
// rendering and stops the native backend. That flag now defaults to *on*
|
||||
// (DR-160, so picture-in-picture has a real surface to shrink into), so the
|
||||
// default no longer selects this path and the tests have to say which path they
|
||||
// are guarding rather than inherit it. (DR-161)
|
||||
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
||||
return {
|
||||
...actual,
|
||||
experimentalNativeVideo: {
|
||||
subscribe: (run: (v: boolean) => void) => {
|
||||
run(false);
|
||||
return () => {};
|
||||
},
|
||||
set: () => {},
|
||||
current: () => false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (channel: string, handler: any) => {
|
||||
channelHandlers[channel] = handler;
|
||||
|
||||
@@ -38,7 +38,13 @@
|
||||
enableNativeVideoCompositing,
|
||||
disableNativeVideoCompositing,
|
||||
} from "$lib/utils/videoSurface";
|
||||
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||
import {
|
||||
isPipSupported,
|
||||
enterPip,
|
||||
setAutoEnterEnabled,
|
||||
setHtml5VideoState,
|
||||
} from "$lib/utils/pictureInPicture";
|
||||
import { enterImmersive, exitImmersive } from "$lib/utils/immersive";
|
||||
import {
|
||||
createTapGestureState,
|
||||
registerTap,
|
||||
@@ -109,8 +115,39 @@
|
||||
endedFired = true;
|
||||
onEnded?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep native's picture-in-picture state in step with the `<video>` element.
|
||||
*
|
||||
* PiP is driven by the Activity, and it only ever knew about the native
|
||||
* ExoPlayer surface — a path behind `experimentalNativeVideo`, which defaults
|
||||
* to off. So in the shipping configuration nothing satisfied its "is a video
|
||||
* playing?" check and the PiP button did nothing at all. Reporting the element
|
||||
* gives it a surface it can shrink into. (UR-041, DR-160)
|
||||
*/
|
||||
function reportPipVideoState() {
|
||||
if (!useHtml5Element || !videoElement) {
|
||||
setHtml5VideoState(false, 0, 0, false);
|
||||
return;
|
||||
}
|
||||
setHtml5VideoState(
|
||||
true,
|
||||
videoElement.videoWidth,
|
||||
videoElement.videoHeight,
|
||||
isPlaying
|
||||
);
|
||||
}
|
||||
let isFullscreen = $state(false);
|
||||
let showControls = $state(true);
|
||||
/**
|
||||
* True while the Activity is in picture-in-picture.
|
||||
*
|
||||
* On the HTML5 path the WebView *is* what PiP shows, so the page has to strip
|
||||
* itself down to the video — controls, header and gradients would otherwise be
|
||||
* rendered into a window a couple of inches wide. (UR-041, DR-160)
|
||||
*/
|
||||
let isInPip = $state(false);
|
||||
let pipListenerCleanup: (() => void) | null = null;
|
||||
let showSleepTimerModal = $state(false);
|
||||
let isBuffering = $state(false);
|
||||
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -810,6 +847,24 @@
|
||||
// Load series audio preference (for TV shows)
|
||||
await loadSeriesAudioPreference();
|
||||
|
||||
// PiP: keep native's view of the `<video>` current, and react to the window
|
||||
// shrinking. The listeners are torn down in onDestroy. (DR-160)
|
||||
reportPipVideoState();
|
||||
const onPipEntered = () => (isInPip = true);
|
||||
const onPipExited = () => (isInPip = false);
|
||||
const onPipPlay = () => void videoElement?.play().catch(() => {});
|
||||
const onPipPause = () => videoElement?.pause();
|
||||
window.addEventListener("jellytau-pip-entered", onPipEntered);
|
||||
window.addEventListener("jellytau-pip-exited", onPipExited);
|
||||
window.addEventListener("jellytau-pip-play", onPipPlay);
|
||||
window.addEventListener("jellytau-pip-pause", onPipPause);
|
||||
pipListenerCleanup = () => {
|
||||
window.removeEventListener("jellytau-pip-entered", onPipEntered);
|
||||
window.removeEventListener("jellytau-pip-exited", onPipExited);
|
||||
window.removeEventListener("jellytau-pip-play", onPipPlay);
|
||||
window.removeEventListener("jellytau-pip-pause", onPipPause);
|
||||
};
|
||||
|
||||
// Report progress every 10 seconds while playing. Live streams have no
|
||||
// meaningful position to report, so skip progress reporting entirely.
|
||||
if (!isLive) {
|
||||
@@ -855,6 +910,16 @@
|
||||
// and idempotent — a no-op when compositing was never enabled.
|
||||
disableNativeVideoCompositing();
|
||||
|
||||
// Same reasoning for the system bars: they belong to the Activity, not to
|
||||
// this component, so a player torn down while immersive would leave every
|
||||
// screen behind it without a status or navigation bar. Idempotent. (UR-066)
|
||||
exitImmersive();
|
||||
|
||||
// The `<video>` is going away, so PiP must stop being offered over it.
|
||||
setHtml5VideoState(false, 0, 0, false);
|
||||
pipListenerCleanup?.();
|
||||
pipListenerCleanup = null;
|
||||
|
||||
// Stop RAF loop
|
||||
stopTimeUpdates();
|
||||
|
||||
@@ -968,6 +1033,9 @@
|
||||
|
||||
function handleLoadedMetadata() {
|
||||
console.log("[VideoPlayer] loadedmetadata event");
|
||||
// Intrinsic dimensions are known now, which is what PiP sizes its window
|
||||
// from — before this they are 0 and the ratio would be rejected. (DR-160)
|
||||
reportPipVideoState();
|
||||
console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
|
||||
console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
|
||||
console.log("[VideoPlayer] Needs transcoding:", needsTranscoding);
|
||||
@@ -1239,6 +1307,8 @@
|
||||
function handlePlay() {
|
||||
isPlaying = true;
|
||||
startTimeUpdates(); // Start RAF loop for smooth time updates
|
||||
// PiP's play/pause action reflects this. (DR-160)
|
||||
reportPipVideoState();
|
||||
// Mirror the DOM state into the Rust PlayerController so it is the single
|
||||
// source of truth for HTML5 video (the <video> lives in the webview, which
|
||||
// Rust cannot observe directly). See html5Adapter.ts.
|
||||
@@ -1268,6 +1338,7 @@
|
||||
);
|
||||
isPlaying = false;
|
||||
stopTimeUpdates(); // Stop RAF loop when paused
|
||||
reportPipVideoState(); // PiP's play/pause action reflects this. (DR-160)
|
||||
html5Adapter.reportState("paused", reportMediaId ?? null);
|
||||
html5Adapter.reportPosition(currentTime, duration, { force: true });
|
||||
// Report progress when paused
|
||||
@@ -1528,12 +1599,24 @@
|
||||
let pendingForegroundSeek: number | null = null;
|
||||
let pendingForegroundPlay = false;
|
||||
|
||||
// On Android the Activity owns the system bars, and requestFullscreen() cannot
|
||||
// reach them — the WebView already spans the window under an edge-to-edge
|
||||
// Activity, so on its own it left the status and navigation bars painted over
|
||||
// the video. The native bridge is what actually makes fullscreen full screen;
|
||||
// requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
// WebKitGTK rejects when the gesture isn't recognised as user-activated;
|
||||
// the immersive call below is what matters on Android, so don't let a
|
||||
// rejection here abort it.
|
||||
console.warn("[VideoPlayer] requestFullscreen rejected:", err);
|
||||
});
|
||||
enterImmersive();
|
||||
isFullscreen = true;
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
exitImmersive();
|
||||
isFullscreen = false;
|
||||
}
|
||||
}
|
||||
@@ -1593,7 +1676,9 @@
|
||||
toggleFullscreen();
|
||||
} else if (e.key === "Escape") {
|
||||
if (isFullscreen) {
|
||||
document.exitFullscreen();
|
||||
// Through the toggle, not document.exitFullscreen() directly: leaving
|
||||
// fullscreen also has to restore the system bars and clear the flag.
|
||||
toggleFullscreen();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
@@ -2090,8 +2175,8 @@
|
||||
style:padding-bottom="calc(1rem + var(--safe-bottom))"
|
||||
style:padding-left="calc(1rem + var(--safe-left))"
|
||||
style:padding-right="calc(1rem + var(--safe-right))"
|
||||
class:opacity-0={!showControls}
|
||||
class:pointer-events-none={!showControls}
|
||||
class:opacity-0={!showControls || isInPip}
|
||||
class:pointer-events-none={!showControls || isInPip}
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="mb-2">
|
||||
|
||||
@@ -87,6 +87,7 @@ vi.mock("$lib/utils/pictureInPicture", () => ({
|
||||
isPipSupported: () => false,
|
||||
enterPip: vi.fn(),
|
||||
setAutoEnterEnabled: vi.fn(),
|
||||
setHtml5VideoState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
|
||||
@@ -26,6 +26,27 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
// ---- Mocks (must precede component import) --------------------------------
|
||||
|
||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||
// These tests pin the **flag-off** interim behaviour: when `experimentalNativeVideo`
|
||||
// is off, VideoPlayer overrides Android's native backend response to HTML5
|
||||
// rendering and stops the native backend. That flag now defaults to *on*
|
||||
// (DR-160, so picture-in-picture has a real surface to shrink into), so the
|
||||
// default no longer selects this path and the tests have to say which path they
|
||||
// are guarding rather than inherit it. (DR-161)
|
||||
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
||||
return {
|
||||
...actual,
|
||||
experimentalNativeVideo: {
|
||||
subscribe: (run: (v: boolean) => void) => {
|
||||
run(false);
|
||||
return () => {};
|
||||
},
|
||||
set: () => {},
|
||||
current: () => false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (channel: string, handler: any) => {
|
||||
channelHandlers[channel] = handler;
|
||||
|
||||
@@ -16,6 +16,7 @@ const OPERATION_LABELS: Record<string, string> = {
|
||||
report_playback_stopped: "Watch position",
|
||||
update_progress: "Watch position",
|
||||
mark_played: "Marked as watched",
|
||||
mark_unplayed: "Marked as unwatched",
|
||||
mark_favorite: "Added to favourites",
|
||||
unmark_favorite: "Removed from favourites",
|
||||
playlist_create: "Playlist created",
|
||||
|
||||
@@ -17,6 +17,7 @@ export type { SyncQueueItem };
|
||||
|
||||
export type SyncOperation =
|
||||
| "mark_played"
|
||||
| "mark_unplayed"
|
||||
| "mark_favorite"
|
||||
| "unmark_favorite"
|
||||
| "update_progress"
|
||||
@@ -101,12 +102,30 @@ class SyncService {
|
||||
* Also updates local state immediately
|
||||
*/
|
||||
async queueMarkPlayed(itemId: string): Promise<number> {
|
||||
// Update local state first
|
||||
await commands.storageMarkPlayed(auth.getUserId() ?? "", itemId);
|
||||
// storageSetWatched, not storageMarkPlayed: this is the watched *toggle*, so
|
||||
// it has to cover a season or series' episodes too. storageMarkPlayed stays
|
||||
// the single-item "this finished playing" path.
|
||||
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, true);
|
||||
|
||||
return this.queueMutation("mark_played", itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue mark as unwatched, the inverse of {@link queueMarkPlayed}.
|
||||
*
|
||||
* Same shape deliberately: the watched toggle has to work in both directions
|
||||
* offline, or un-marking would be the one half that needs a connection. The
|
||||
* drain pushes this as `clear_watch_history` — Jellyfin's mark-unplayed, which
|
||||
* is recursive over a season or series and also clears resume positions.
|
||||
*
|
||||
* TRACES: UR-073 | DR-158
|
||||
*/
|
||||
async queueMarkUnplayed(itemId: string): Promise<number> {
|
||||
await commands.storageSetWatched(auth.getUserId() ?? "", itemId, false);
|
||||
|
||||
return this.queueMutation("mark_unplayed", itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending sync operations
|
||||
*/
|
||||
|
||||
@@ -26,13 +26,27 @@ const STORAGE_KEY = "jellytau-experimental-native-video";
|
||||
/** The attribute app.css keys its transparency rules off. */
|
||||
const NATIVE_VIDEO_ATTR = "data-native-video";
|
||||
|
||||
/**
|
||||
* Whether the native path is on, defaulting to **on** when the user has never
|
||||
* chosen.
|
||||
*
|
||||
* It shipped defaulting to off while the native path was a spike. It is now the
|
||||
* default because picture-in-picture is built on it: PiP shrinks the *Activity*,
|
||||
* so it needs a real video surface behind the WebView to show, and on the HTML5
|
||||
* path there is nothing for it to shrink into but the UI itself (DR-160).
|
||||
*
|
||||
* An explicit stored choice still wins in both directions, so anyone who turned
|
||||
* it off keeps it off.
|
||||
*/
|
||||
function load(): boolean {
|
||||
if (typeof localStorage === "undefined") return false;
|
||||
if (typeof localStorage === "undefined") return true;
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored === null ? true : stored === "true";
|
||||
} catch {
|
||||
// Private-mode / disabled storage — default to the safe (HTML5) path.
|
||||
return false;
|
||||
// Private-mode / disabled storage — no stored choice is readable, so this is
|
||||
// the same case as "never chosen".
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Immersive (system-bar-free) full-screen video, Android only.
|
||||
*
|
||||
* TRACES: UR-066 | DR-157
|
||||
*
|
||||
* `requestFullscreen()` is the only fullscreen control the web layer has, and in
|
||||
* an Android WebView it does not touch the Activity window — it expands the
|
||||
* element inside a viewport that already spans the whole screen (MainActivity
|
||||
* calls `enableEdgeToEdge()`, and SDK 36 makes that mandatory). So the status and
|
||||
* navigation bars stayed painted over full-screen video, and "fullscreen"
|
||||
* changed nothing visible.
|
||||
*
|
||||
* Hiding them needs `WindowInsetsControllerCompat` on the Activity, so it goes
|
||||
* through the `AndroidImmersive` @JavascriptInterface installed by MainActivity.
|
||||
* Elsewhere (desktop, the Linux WebKitGTK webview) the real `requestFullscreen()`
|
||||
* already does the right thing and these calls are no-ops.
|
||||
*/
|
||||
|
||||
interface AndroidImmersiveBridge {
|
||||
enter(): void;
|
||||
exit(): void;
|
||||
isSupported(): boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AndroidImmersive?: AndroidImmersiveBridge;
|
||||
}
|
||||
}
|
||||
|
||||
function bridge(): AndroidImmersiveBridge | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return window.AndroidImmersive;
|
||||
}
|
||||
|
||||
/** Whether native immersive mode exists on this platform. */
|
||||
export function isImmersiveSupported(): boolean {
|
||||
try {
|
||||
return bridge()?.isSupported() ?? false;
|
||||
} catch (err) {
|
||||
console.warn("[Immersive] isSupported check failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hide the system bars. No-op where unsupported. */
|
||||
export function enterImmersive(): void {
|
||||
try {
|
||||
bridge()?.enter();
|
||||
} catch (err) {
|
||||
console.error("[Immersive] Failed to hide the system bars:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the system bars. No-op where unsupported.
|
||||
*
|
||||
* Call this on leaving fullscreen *and* on player teardown — the bars belong to
|
||||
* the Activity, not the player, so a player destroyed while immersive would
|
||||
* leave every screen behind it without a status or navigation bar.
|
||||
*/
|
||||
export function exitImmersive(): void {
|
||||
try {
|
||||
bridge()?.exit();
|
||||
} catch (err) {
|
||||
console.error("[Immersive] Failed to restore the system bars:", err);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ interface AndroidPictureInPictureBridge {
|
||||
isSupported(): boolean;
|
||||
canEnterPip(): boolean;
|
||||
setAutoEnterEnabled(enabled: boolean): void;
|
||||
setHtml5VideoState(active: boolean, width: number, height: number, playing: boolean): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -84,3 +85,32 @@ export function setAutoEnterEnabled(enabled: boolean): void {
|
||||
console.warn("[PiP] Failed to set auto-enter:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell native that a WebView `<video>` is (or is no longer) the playback surface.
|
||||
*
|
||||
* This is what makes PiP work at all in the shipping configuration. The native
|
||||
* side only ever knew about the ExoPlayer surface, and that path is behind
|
||||
* `experimentalNativeVideo`, which defaults to off — so `canEnterPip` was always
|
||||
* false and pressing the button did nothing. Reporting the element's state gives
|
||||
* native a surface it can legitimately shrink into, plus the intrinsic size it
|
||||
* needs for the PiP window's aspect ratio and the play state for its play/pause
|
||||
* action.
|
||||
*
|
||||
* Pass `active: false` when the element goes away, or PiP would be offered over a
|
||||
* video that is no longer there.
|
||||
*
|
||||
* TRACES: UR-041 | DR-160
|
||||
*/
|
||||
export function setHtml5VideoState(
|
||||
active: boolean,
|
||||
width: number,
|
||||
height: number,
|
||||
playing: boolean
|
||||
): void {
|
||||
try {
|
||||
bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing);
|
||||
} catch (err) {
|
||||
console.warn("[PiP] Failed to report HTML5 video state:", err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Wires a persistent scroll container to the per-route scroll memory.
|
||||
*
|
||||
* The decision logic is pure and lives in `scrollRestore.ts`; this is the thin
|
||||
* DOM/SvelteKit half. Call it once at component init (SvelteKit's navigation
|
||||
* hooks must be registered during initialisation, not from `onMount`), passing
|
||||
* a getter for the element — the element itself is bound later, so a getter is
|
||||
* the only way to hand it over from the top of `<script>`.
|
||||
*
|
||||
* let scroller: HTMLElement | undefined = $state();
|
||||
* useScrollRestore(() => scroller, "library");
|
||||
* …
|
||||
* <div bind:this={scroller} class="flex-1 overflow-y-auto">
|
||||
*
|
||||
* Memories are keyed by container id and held at module scope, not per call.
|
||||
* Two containers must never share one (the root, home and library scrollers
|
||||
* hold different content for the same URL, so a shared map would restore one
|
||||
* into another) — but a container that *remounts* has to find its offsets again
|
||||
* when it comes back. The home scroller is destroyed on every navigation away,
|
||||
* so a memory owned by the component instance would be empty on return and Back
|
||||
* could only ever land at the top.
|
||||
*
|
||||
* TRACES: UR-072 | DR-156
|
||||
*/
|
||||
|
||||
import { beforeNavigate, afterNavigate } from "$app/navigation";
|
||||
import { tick } from "svelte";
|
||||
import { ScrollMemory, classifyNavigation, scrollKey } from "./scrollRestore";
|
||||
|
||||
/** Container id → its offsets. Outlives the components that mount them. */
|
||||
const memories = new Map<string, ScrollMemory>();
|
||||
|
||||
function memoryFor(containerId: string): ScrollMemory {
|
||||
let memory = memories.get(containerId);
|
||||
if (!memory) {
|
||||
memory = new ScrollMemory();
|
||||
memories.set(containerId, memory);
|
||||
}
|
||||
return memory;
|
||||
}
|
||||
|
||||
/** Forget every container's offsets. For sign-out and tests. */
|
||||
export function clearScrollMemories(): void {
|
||||
memories.clear();
|
||||
}
|
||||
|
||||
export function useScrollRestore(
|
||||
getElement: () => HTMLElement | null | undefined,
|
||||
containerId: string
|
||||
): void {
|
||||
const memory = memoryFor(containerId);
|
||||
|
||||
// Record where we were before the route changes. `nav.from` is absent on the
|
||||
// very first navigation, which is exactly when there is nothing to save.
|
||||
beforeNavigate((nav) => {
|
||||
const element = getElement();
|
||||
if (!element || !nav.from) return;
|
||||
memory.save(scrollKey(nav.from.url), element.scrollTop);
|
||||
});
|
||||
|
||||
afterNavigate(async (nav) => {
|
||||
const target = nav.to;
|
||||
if (!target) return;
|
||||
|
||||
const action = memory.decide(scrollKey(target.url), classifyNavigation(nav));
|
||||
if (action.kind === "none") return;
|
||||
|
||||
const top = action.kind === "restore" ? action.top : 0;
|
||||
|
||||
// Wait for the new route's markup to be in the DOM before moving the
|
||||
// scroller — setting scrollTop past the current content height is clamped,
|
||||
// and a reset applied too early is undone by the incoming render.
|
||||
await tick();
|
||||
const element = getElement();
|
||||
if (!element) return;
|
||||
|
||||
element.scrollTop = top;
|
||||
|
||||
// A restore often targets content that is still loading (a library grid
|
||||
// fetches after mount), so the offset would clamp to a short page. Re-apply
|
||||
// on the next frame, once, which is enough for the common case without
|
||||
// fighting a user who has already started scrolling.
|
||||
if (action.kind === "restore" && top > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
const el = getElement();
|
||||
if (el && el.scrollTop < top) el.scrollTop = top;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { ScrollMemory, classifyNavigation } from "./scrollRestore";
|
||||
|
||||
describe("classifyNavigation", () => {
|
||||
it("treats the initial page load as an entry", () => {
|
||||
expect(classifyNavigation({ type: "enter" })).toBe("enter");
|
||||
});
|
||||
|
||||
it("treats back/forward gestures as a popstate", () => {
|
||||
expect(classifyNavigation({ type: "popstate" })).toBe("popstate");
|
||||
});
|
||||
|
||||
it("treats link and goto navigations as forward moves", () => {
|
||||
expect(classifyNavigation({ type: "link" })).toBe("forward");
|
||||
expect(classifyNavigation({ type: "goto" })).toBe("forward");
|
||||
expect(classifyNavigation({ type: "form" })).toBe("forward");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ScrollMemory", () => {
|
||||
let memory: ScrollMemory;
|
||||
|
||||
beforeEach(() => {
|
||||
memory = new ScrollMemory();
|
||||
});
|
||||
|
||||
// The bug: a scroll container that lives in a persistent layout keeps its
|
||||
// offset across a forward navigation, so a page opened from a scrolled list
|
||||
// starts part-way down. A forward move must always land at the top.
|
||||
it("resets to the top on a forward navigation, even from a scrolled page", () => {
|
||||
memory.save("/library", 1200);
|
||||
|
||||
expect(memory.decide("/library/abc123", "forward")).toEqual({ kind: "reset" });
|
||||
});
|
||||
|
||||
it("resets to the top when navigating forward to a page seen before", () => {
|
||||
memory.save("/library", 1200);
|
||||
memory.save("/search", 340);
|
||||
|
||||
// Re-entering /library by tapping a nav link is a fresh visit, not a Back.
|
||||
expect(memory.decide("/library", "forward")).toEqual({ kind: "reset" });
|
||||
});
|
||||
|
||||
it("restores the saved offset on Back", () => {
|
||||
memory.save("/library", 1200);
|
||||
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 1200 });
|
||||
});
|
||||
|
||||
it("restores the top when Back targets a page with no saved offset", () => {
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 0 });
|
||||
});
|
||||
|
||||
it("keeps offsets per route rather than sharing one across pages", () => {
|
||||
memory.save("/library", 1200);
|
||||
memory.save("/search", 340);
|
||||
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 1200 });
|
||||
expect(memory.decide("/search", "popstate")).toEqual({ kind: "restore", top: 340 });
|
||||
});
|
||||
|
||||
it("leaves the container alone on the initial load", () => {
|
||||
expect(memory.decide("/", "enter")).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("overwrites a stale offset when the same route is saved again", () => {
|
||||
memory.save("/library", 1200);
|
||||
memory.save("/library", 80);
|
||||
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 80 });
|
||||
});
|
||||
|
||||
it("forgets nothing on decide, so a repeated Back still restores", () => {
|
||||
memory.save("/library", 1200);
|
||||
|
||||
memory.decide("/library", "popstate");
|
||||
expect(memory.decide("/library", "popstate")).toEqual({ kind: "restore", top: 1200 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Per-route scroll memory for the app's persistent scroll containers.
|
||||
*
|
||||
* The shell keeps its scrollers alive across navigation on purpose: the root
|
||||
* layout, the home page and the library layout each own a
|
||||
* `flex-1 overflow-y-auto` box that outlives the route rendered inside it. That
|
||||
* is what makes the bottom UI a flex sibling rather than a measured overlay —
|
||||
* but it also means the *element* never remounts, so its `scrollTop` survives a
|
||||
* route change and the next page opens part-way down.
|
||||
*
|
||||
* SvelteKit's own scroll restoration cannot help here: it saves and restores
|
||||
* `window` scroll, and in this app the window never scrolls at all.
|
||||
*
|
||||
* So each container gets its own memory, which reproduces normal browser
|
||||
* behaviour:
|
||||
*
|
||||
* - **forward** (link/goto/form) — a fresh visit, always lands at the top;
|
||||
* - **popstate** (hardware/gesture Back or Forward) — restores the offset the
|
||||
* route was left at, so Back out of a detail page returns you to your place
|
||||
* in the list rather than to the top of it;
|
||||
* - **enter** (initial load) — left alone; there is nothing to leak yet.
|
||||
*
|
||||
* The decision is pure and lives here so it can be unit-tested without a DOM;
|
||||
* `scrollContainer.svelte.ts` is the thin action that applies it.
|
||||
*
|
||||
* TRACES: UR-054 | DR-156
|
||||
*/
|
||||
|
||||
/** How a navigation should affect a persistent scroll container. */
|
||||
export type NavKind = "enter" | "popstate" | "forward";
|
||||
|
||||
/** What to do with the container once the new route has rendered. */
|
||||
export type ScrollAction =
|
||||
| { kind: "reset" }
|
||||
| { kind: "restore"; top: number }
|
||||
| { kind: "none" };
|
||||
|
||||
/**
|
||||
* Collapse SvelteKit's navigation types into the three cases that matter.
|
||||
*
|
||||
* `enter` is the initial load. `popstate` is a Back/Forward gesture. Everything
|
||||
* else — `link`, `goto`, `form` — is a forward move into a new page.
|
||||
*/
|
||||
export function classifyNavigation(nav: { type?: string | null }): NavKind {
|
||||
if (nav.type === "enter") return "enter";
|
||||
if (nav.type === "popstate") return "popstate";
|
||||
return "forward";
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the offset each route was left at, for one scroll container.
|
||||
*
|
||||
* One instance per container: the root scroller, the home scroller and the
|
||||
* library scroller hold different content for the same URL, so a shared map
|
||||
* would restore one container's offset into another.
|
||||
*/
|
||||
export class ScrollMemory {
|
||||
#offsets = new Map<string, number>();
|
||||
|
||||
/** Record where `key` was scrolled to, before we navigate away from it. */
|
||||
save(key: string, top: number): void {
|
||||
this.#offsets.set(key, Math.max(0, top));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what the container should do on arriving at `key`.
|
||||
*
|
||||
* Note this does not consume the saved offset: a route can be returned to
|
||||
* more than once, and each Back should restore the same place.
|
||||
*/
|
||||
decide(key: string, kind: NavKind): ScrollAction {
|
||||
if (kind === "enter") return { kind: "none" };
|
||||
if (kind === "popstate") return { kind: "restore", top: this.#offsets.get(key) ?? 0 };
|
||||
return { kind: "reset" };
|
||||
}
|
||||
|
||||
/** Drop everything. Intended for tests and sign-out. */
|
||||
clear(): void {
|
||||
this.#offsets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The memory key for a URL.
|
||||
*
|
||||
* Path plus query: a library grid filtered by genre is a different list from
|
||||
* the unfiltered one, and returning to it should restore its own place.
|
||||
*/
|
||||
export function scrollKey(url: { pathname: string; search?: string }): string {
|
||||
return `${url.pathname}${url.search ?? ""}`;
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
shellReservesBottomInset,
|
||||
} from "$lib/utils/layoutShell";
|
||||
import { registerNavigationTracking } from "$lib/utils/navigation";
|
||||
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
||||
import { startNetworkReporting } from "$lib/services/networkType";
|
||||
import { initSafeArea } from "$lib/utils/safeArea";
|
||||
|
||||
@@ -52,6 +53,12 @@
|
||||
// context, not the async onMount callback below.
|
||||
registerNavigationTracking();
|
||||
|
||||
// The shell's scroller outlives every route rendered into it, so without this
|
||||
// a new page inherits the previous page's offset. Must be registered here at
|
||||
// init, alongside the tracker above, for the same reason. (DR-156)
|
||||
let shellScroller = $state<HTMLElement>();
|
||||
useScrollRestore(() => shellScroller, "shell");
|
||||
|
||||
// Layout-shell visibility rules live in one pure, unit-tested module
|
||||
// ($lib/utils/layoutShell) so they can't drift per route/platform.
|
||||
//
|
||||
@@ -313,6 +320,7 @@
|
||||
sibling, so the list is physically bounded above it and can never
|
||||
render behind it. No measurement, no reserved padding. -->
|
||||
<div
|
||||
bind:this={shellScroller}
|
||||
class="flex-1 overflow-y-auto min-h-0"
|
||||
style="overscroll-behavior: contain"
|
||||
>
|
||||
|
||||
@@ -10,8 +10,15 @@
|
||||
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
||||
import Carousel from "$lib/components/home/Carousel.svelte";
|
||||
import MediaCard from "$lib/components/library/MediaCard.svelte";
|
||||
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
|
||||
// Home scrolls in its own box rather than the shell's, and is destroyed on
|
||||
// every navigation away — so its offsets live in the module-level memory,
|
||||
// letting Back return the viewer to their row instead of the top. (DR-156)
|
||||
let homeScroller = $state<HTMLElement>();
|
||||
useScrollRestore(() => homeScroller, "home");
|
||||
|
||||
// Track if we've done an initial load (plain variable, not reactive)
|
||||
let hasLoadedOnce = false;
|
||||
let previousServerReachable = false;
|
||||
@@ -147,7 +154,7 @@
|
||||
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
|
||||
<div bind:this={homeScroller} class="h-full overflow-y-auto p-4 pb-16 md:pb-4 {isAndroid && $currentMedia && $currentMedia.type !== 'Movie' && $currentMedia.type !== 'Episode' ? 'pb-40' : ''}">
|
||||
<div class="space-y-8">
|
||||
|
||||
<!-- Hero Banner -->
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { isAuthenticated, isLoading as isAuthLoading } from "$lib/stores/auth";
|
||||
import { useScrollGuard } from "$lib/composables/useScrollGuard";
|
||||
import { useScrollRestore } from "$lib/utils/scrollContainer";
|
||||
import AppHeader from "$lib/components/AppHeader.svelte";
|
||||
import BottomUi from "$lib/components/BottomUi.svelte";
|
||||
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
|
||||
@@ -11,6 +12,12 @@
|
||||
const scrollGuard = useScrollGuard(300);
|
||||
setContext("scrollGuard", scrollGuard);
|
||||
|
||||
// This scroller outlives every /library/* route rendered into it, so opening
|
||||
// an item from half-way down a grid used to drop the viewer half-way down the
|
||||
// detail page. Registered at init, as SvelteKit's nav hooks require. (DR-156)
|
||||
let libraryScroller = $state<HTMLElement>();
|
||||
useScrollRestore(() => libraryScroller, "library");
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let showSleepTimerModal = $state(false);
|
||||
@@ -45,6 +52,7 @@
|
||||
scroller is physically bounded above it and its last row can never
|
||||
render behind the nav — no measurement, no reserved padding. -->
|
||||
<main
|
||||
bind:this={libraryScroller}
|
||||
class="flex-1 overflow-y-auto p-4 min-h-0"
|
||||
style="overscroll-behavior: contain"
|
||||
onscroll={scrollGuard.onScroll}
|
||||
|
||||
@@ -243,6 +243,35 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<!-- Favourites as a destination in its own right, not just the icon in
|
||||
the header above. It cuts across every library, so it leads the
|
||||
grid rather than sitting inside one — and a labelled tile at the
|
||||
same weight as a library is the difference between a feature
|
||||
people find and one they don't. ux-flows §5C.2.
|
||||
TRACES: UR-067 | DR-117 -->
|
||||
<button
|
||||
onclick={() => goto('/library/favorites')}
|
||||
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105"
|
||||
>
|
||||
<div
|
||||
class="relative aspect-video w-full overflow-hidden rounded-lg shadow-md
|
||||
flex items-center justify-center
|
||||
bg-gradient-to-br from-[var(--color-jellyfin)]/30 to-[var(--color-jellyfin)]/5"
|
||||
>
|
||||
<svg
|
||||
class="w-10 h-10 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
||||
Favourites
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{#each visibleLibraries as lib (lib.id)}
|
||||
<MediaCard
|
||||
item={lib}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
|
||||
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
|
||||
import ClearHistoryButton from "$lib/components/library/ClearHistoryButton.svelte";
|
||||
import WatchedToggleButton from "$lib/components/library/WatchedToggleButton.svelte";
|
||||
import VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
@@ -549,6 +550,14 @@
|
||||
seriesName={item.name}
|
||||
episodeCount={allEpisodes.length || undefined}
|
||||
/>
|
||||
<WatchedToggleButton
|
||||
itemId={item.id}
|
||||
watched={allEpisodes.length > 0 &&
|
||||
allEpisodes.every((e) => e.userData?.isPlayed)}
|
||||
scope="series"
|
||||
showLabel={true}
|
||||
onChanged={loadItem}
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={item.id}
|
||||
itemName={item.name}
|
||||
@@ -562,6 +571,14 @@
|
||||
isMovie={true}
|
||||
size="lg"
|
||||
/>
|
||||
<!-- A movie is a leaf, so its own played flag is the whole story. -->
|
||||
<WatchedToggleButton
|
||||
itemId={item.id}
|
||||
watched={item.userData?.isPlayed ?? false}
|
||||
scope="episode"
|
||||
showLabel={true}
|
||||
onChanged={loadItem}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Favourite. Sits with Play/Download rather than in the header,
|
||||
per ux-flows §5B.3/§5B.4. TRACES: UR-068 | DR-119 -->
|
||||
|
||||
@@ -697,8 +697,10 @@
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400 mt-1">
|
||||
Decode video with the device's hardware decoder instead of the
|
||||
built-in web player. Better performance and battery life, but
|
||||
less tested — turn this off if video fails to appear.
|
||||
built-in web player. Better performance and battery life, and
|
||||
required for picture-in-picture to show the video rather than
|
||||
the app. Still less tested — turn this off if video fails to
|
||||
appear or seeking misbehaves.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user