Files
jellytau/src/lib/components/library/SeasonSection.svelte
T
dtourolle 9f5f57cba4 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.
2026-08-15 16:26:31 +02:00

177 lines
6.3 KiB
Svelte

<!-- TRACES: UR-062, UR-064 | DR-102, DR-103, DR-106, DR-107 -->
<script lang="ts">
import type { MediaItem } from "$lib/api/types";
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";
interface Props {
season: MediaItem;
episodes: MediaItem[];
focusedEpisodeId?: string;
/** The episode the viewer is up to — highlighted and scrolled into view. */
currentEpisodeId?: string;
/**
* Whether this season's episode list is open. Only the current season
* starts expanded, so a ten-season show does not render every episode at
* once. TRACES: UR-062 | DR-107
*/
expanded?: boolean;
onToggle?: () => void;
onEpisodeClick?: (episode: MediaItem) => void;
onHistoryCleared?: () => void;
}
let {
season,
episodes,
focusedEpisodeId,
currentEpisodeId,
expanded = false,
onToggle,
onEpisodeClick,
onHistoryCleared,
}: Props = $props();
const holdsCurrentEpisode = $derived(
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId)
);
const watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length);
const episodeCount = $derived(episodes.length);
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
const seasonName = $derived(
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season")
);
// Seasons have no page of their own; a season link scrolls to this anchor
// inside the series' single continuous episode list.
const anchor = $derived(seasonAnchorId(seasonNumber));
</script>
<section class="space-y-4 scroll-mt-4" id={anchor}>
<!-- Season header -->
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
<!-- Season poster -->
<div class="flex-shrink-0 w-20 aspect-[2/3] rounded-lg overflow-hidden bg-[var(--color-background)]">
<CachedImage
itemId={season.id}
imageType="Primary"
tag={season.imageId}
maxWidth={200}
alt={seasonName}
class="w-full h-full object-cover"
/>
</div>
<!-- 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 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="min-w-0 sm:flex-1 text-left group/season"
>
<!-- 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' : ''}"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<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"
>
Up next
</span>
{/if}
</h2>
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400 pl-7">
<span>{episodeCount} {episodeCount === 1 ? "Episode" : "Episodes"}</span>
<!-- Collapsed, this is the only progress signal the season shows. -->
{#if watchedCount > 0}
<span></span>
<span>
{watchedCount === episodeCount ? "Watched" : `${watchedCount} watched`}
</span>
{/if}
{#if season.productionYear}
<span></span>
<span>{season.productionYear}</span>
{/if}
</div>
{#if season.overview && expanded}
<p class="text-gray-400 text-sm mt-3 line-clamp-3 pl-7">
{season.overview}
</p>
{/if}
</button>
<!-- 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 || ""}
seasonName={seasonName}
seasonNumber={season.indexNumber || season.parentIndexNumber || 0}
{episodeCount}
size="sm"
/>
<WatchedToggleButton
itemId={season.id}
watched={watchedCount === episodeCount && episodeCount > 0}
scope="season"
size="sm"
onChanged={onHistoryCleared}
/>
<ClearHistoryButton
itemId={season.id}
itemName={seasonName}
scope="season"
size="sm"
onCleared={onHistoryCleared}
/>
</div>
</div>
</div>
</div>
<!-- Episode list -->
{#if expanded}
<div class="space-y-1 pl-2" id="{anchor}-episodes">
{#each episodes as episode (episode.id)}
<EpisodeRow
{episode}
focused={episode.id === focusedEpisodeId}
current={episode.id === currentEpisodeId}
onclick={() => onEpisodeClick?.(episode)}
onWatchedChanged={onHistoryCleared}
/>
{/each}
</div>
{/if}
</section>