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.
177 lines
6.3 KiB
Svelte
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>
|