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:
2026-08-15 16:26:31 +02:00
parent 50934e2ac6
commit 9f5f57cba4
42 changed files with 1548 additions and 139 deletions
+27
View File
@@ -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)}
+20 -1
View File
@@ -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;
+90 -5
View File
@@ -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;
+1
View File
@@ -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",
+21 -2
View File
@@ -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
*/
+18 -4
View File
@@ -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;
}
}
+68
View File
@@ -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);
}
}
+30
View File
@@ -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);
}
}
+90
View File
@@ -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;
});
}
});
}
+79
View File
@@ -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 });
});
});
+91
View File
@@ -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 ?? ""}`;
}
+8
View File
@@ -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"
>
+8 -1
View File
@@ -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 -->
+8
View File
@@ -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}
+29
View File
@@ -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}
+17
View File
@@ -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 -->
+4 -2
View File
@@ -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