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
@@ -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>