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.
381 lines
14 KiB
Svelte
381 lines
14 KiB
Svelte
<!--
|
|
The one and only episode surface (ux-flows §5B.1) — so it carries everything
|
|
an episode can do, not just Play. A bare Episode page used to exist alongside
|
|
it with a *different* set of affordances (download, breadcrumbs, cast), which
|
|
meant opening an episode from Continue Watching silently lost them.
|
|
|
|
TRACES: UR-048, UR-058 | DR-061, DR-062, DR-142
|
|
-->
|
|
<script lang="ts">
|
|
import { goto } from "$app/navigation";
|
|
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
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";
|
|
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
|
import { seasonAnchorId } from "./seriesNavigation";
|
|
import {
|
|
isCurrentEpisode as isSameEpisode,
|
|
adjacentEpisodes as computeAdjacent,
|
|
stripCardLabel,
|
|
} from "./episodeStrip";
|
|
|
|
interface Props {
|
|
episode: MediaItem;
|
|
/**
|
|
* The parent series. `null` only for an episode that carries no `seriesId`
|
|
* (a deep link into a stale cache) — the view still renders, minus the
|
|
* affordances that need series context.
|
|
*/
|
|
series?: MediaItem | null;
|
|
allEpisodes?: MediaItem[];
|
|
onBack?: () => void;
|
|
}
|
|
|
|
let { episode, series = null, allEpisodes = [], onBack }: Props = $props();
|
|
|
|
// Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use.
|
|
function isCurrentEpisode(ep: MediaItem): boolean {
|
|
return isSameEpisode(ep, episode);
|
|
}
|
|
|
|
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
|
|
|
|
// A strip of exactly one card is the current episode talking to itself — the
|
|
// spec wants the *next* episodes, so with no siblings there is nothing to show.
|
|
const hasEpisodeStrip = $derived(adjacentEpisodes().length > 1);
|
|
|
|
// Compute best backdrop source (no fetch, pure derivation)
|
|
const backdropSource = $derived.by(() => {
|
|
if (episode.backdropImageTags?.[0]) {
|
|
return { itemId: episode.id, imageType: "Backdrop" as const, tag: episode.backdropImageTags[0] };
|
|
}
|
|
if (episode.imageId) {
|
|
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
|
|
}
|
|
if (series?.backdropImageTags?.[0]) {
|
|
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
|
|
}
|
|
return null;
|
|
});
|
|
|
|
// Cast and genres are the episode's own when the server sent them, else the
|
|
// series' — a list-level episode fetch often carries neither, and an empty
|
|
// Cast row on an episode of a show with a known cast reads as broken.
|
|
const people = $derived(episode.people?.length ? episode.people : series?.people ?? []);
|
|
const genres = $derived(episode.genres?.length ? episode.genres : series?.genres ?? []);
|
|
|
|
// "More Like This" on an episode means similar *shows* (UR-048), so it keys
|
|
// off the series rather than the episode.
|
|
const seriesName = $derived(series?.name ?? episode.seriesName ?? null);
|
|
const seriesHref = $derived(series ? `/library/${series.id}` : null);
|
|
const seasonHref = $derived(
|
|
series && episode.parentIndexNumber != null
|
|
? `/library/${series.id}#${seasonAnchorId(episode.parentIndexNumber)}`
|
|
: null
|
|
);
|
|
|
|
function formatDuration(ms?: number | null): string {
|
|
if (!ms) return "";
|
|
const seconds = Math.floor(ms / 1000);
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes}m`;
|
|
}
|
|
return `${minutes}m`;
|
|
}
|
|
|
|
function getProgress(ep: MediaItem): number {
|
|
if (!ep.userData || !ep.durationMs) {
|
|
return 0;
|
|
}
|
|
return ((ep.userData.playbackPositionMs ?? 0) / ep.durationMs) * 100;
|
|
}
|
|
|
|
function handlePlay() {
|
|
goto(`/player/${episode.id}`);
|
|
}
|
|
|
|
function handleEpisodeClick(ep: MediaItem) {
|
|
if (!series) return;
|
|
goto(`/library/${series.id}?episode=${ep.id}`);
|
|
}
|
|
|
|
const episodeLabel = $derived(
|
|
`S${episode.parentIndexNumber || 1}E${episode.indexNumber || 1}`
|
|
);
|
|
const duration = $derived(formatDuration(episode.durationMs));
|
|
const progress = $derived(getProgress(episode));
|
|
</script>
|
|
|
|
<div class="space-y-8">
|
|
<!-- Hero section -->
|
|
<div class="relative h-[450px] rounded-xl overflow-hidden">
|
|
{#if backdropSource}
|
|
<CachedImage
|
|
itemId={backdropSource.itemId}
|
|
imageType={backdropSource.imageType}
|
|
tag={backdropSource.tag}
|
|
maxWidth={1920}
|
|
alt={episode.name}
|
|
class="absolute inset-0 w-full h-full object-cover"
|
|
/>
|
|
{:else}
|
|
<div class="absolute inset-0 bg-gradient-to-br from-[var(--color-jellyfin)] to-purple-900"></div>
|
|
{/if}
|
|
|
|
<!-- Gradient overlay -->
|
|
<div class="absolute inset-0 bg-gradient-to-r from-black/90 via-black/60 to-transparent"></div>
|
|
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
|
|
|
|
<!-- Back button -->
|
|
{#if onBack}
|
|
<button
|
|
onclick={onBack}
|
|
class="absolute top-4 left-4 p-2 rounded-full bg-black/50 hover:bg-black/70 transition-colors"
|
|
title="Back to series"
|
|
>
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- Content -->
|
|
<div class="relative h-full flex flex-col justify-end p-8 max-w-3xl">
|
|
<div class="space-y-4">
|
|
<!-- Series name — a link, so the episode page is a navigable hub
|
|
rather than a dead end (UR-058). -->
|
|
{#if seriesName}
|
|
<p class="text-lg">
|
|
{#if seriesHref}
|
|
<a href={seriesHref} class="text-gray-300 hover:text-white hover:underline transition-colors">
|
|
{seriesName}
|
|
</a>
|
|
{:else}
|
|
<span class="text-gray-300">{seriesName}</span>
|
|
{/if}
|
|
</p>
|
|
{/if}
|
|
|
|
<!-- Episode title -->
|
|
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
|
|
{truncateMiddle(episode.name, 64)}
|
|
</h1>
|
|
|
|
<!-- Metadata -->
|
|
<div class="flex items-center gap-4 text-sm text-gray-200">
|
|
<!-- The badge links to the season's place in the series list —
|
|
seasons have no page of their own (DR-103). -->
|
|
{#if seasonHref}
|
|
<a
|
|
href={seasonHref}
|
|
class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold hover:brightness-110 transition-all"
|
|
>
|
|
{episodeLabel}
|
|
</a>
|
|
{:else}
|
|
<span class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold">
|
|
{episodeLabel}
|
|
</span>
|
|
{/if}
|
|
{#if duration}
|
|
<span>{duration}</span>
|
|
{/if}
|
|
{#if episode.communityRating}
|
|
<span class="flex items-center gap-1">
|
|
<svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
|
|
</svg>
|
|
{episode.communityRating.toFixed(1)}
|
|
</span>
|
|
{/if}
|
|
{#if episode.userData?.isPlayed}
|
|
<span class="flex items-center gap-1 text-[var(--color-jellyfin)]">
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
</svg>
|
|
Watched
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Overview -->
|
|
{#if episode.overview}
|
|
<p class="text-gray-200 line-clamp-3 text-lg leading-relaxed max-w-2xl">
|
|
{episode.overview}
|
|
</p>
|
|
{/if}
|
|
|
|
<!-- Progress bar if in progress -->
|
|
{#if progress > 0 && progress < 95}
|
|
<div class="w-64">
|
|
<div class="h-1 bg-gray-700 rounded-full overflow-hidden">
|
|
<div
|
|
class="h-full bg-[var(--color-jellyfin)]"
|
|
style="width: {progress}%"
|
|
></div>
|
|
</div>
|
|
<p class="text-xs text-gray-400 mt-1">
|
|
{Math.round(progress)}% watched
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Play / Download / Favourite — the full hero action row of
|
|
ux-flows §5B.2. TRACES: UR-058, UR-068 | DR-119, DR-142 -->
|
|
<div class="pt-2 flex items-center gap-3">
|
|
<button
|
|
onclick={handlePlay}
|
|
class="px-8 py-3 bg-white text-black hover:bg-white/90 rounded-lg font-semibold text-lg flex items-center gap-2 transition-colors"
|
|
>
|
|
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
{progress > 0 && progress < 95 ? "Resume" : "Play"}
|
|
</button>
|
|
<VideoDownloadButton
|
|
itemId={episode.id}
|
|
itemName={episode.name}
|
|
isMovie={false}
|
|
seriesName={seriesName ?? undefined}
|
|
seasonName={episode.seasonName ?? undefined}
|
|
seasonNumber={episode.parentIndexNumber ?? undefined}
|
|
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)}
|
|
size="lg"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Adjacent episodes. Nothing may be inserted between the hero and this
|
|
strip — continuation content comes before discovery content
|
|
(ux-flows §5B.2). TRACES: UR-048 | DR-061, DR-062 -->
|
|
{#if hasEpisodeStrip}
|
|
<div class="space-y-4">
|
|
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
|
|
|
|
<div class="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent">
|
|
{#each adjacentEpisodes() as ep (ep.id)}
|
|
{@const isCurrent = isCurrentEpisode(ep)}
|
|
{@const epProgress = getProgress(ep)}
|
|
<button
|
|
onclick={() => !isCurrent && handleEpisodeClick(ep)}
|
|
class="flex-shrink-0 w-64 text-left group/card {isCurrent ? 'ring-2 ring-yellow-400 rounded-lg' : ''}"
|
|
disabled={isCurrent}
|
|
>
|
|
<!-- Thumbnail -->
|
|
<div class="relative aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]">
|
|
<CachedImage
|
|
itemId={ep.id}
|
|
imageType="Primary"
|
|
tag={ep.imageId}
|
|
maxWidth={400}
|
|
alt={ep.name}
|
|
class="w-full h-full object-cover transition-transform {isCurrent ? '' : 'group-hover/card:scale-105'}"
|
|
/>
|
|
|
|
<!-- Hover overlay -->
|
|
{#if !isCurrent}
|
|
<div class="absolute inset-0 bg-black/0 group-hover/card:bg-black/30 transition-colors flex items-center justify-center">
|
|
<div class="opacity-0 group-hover/card:opacity-100 transition-opacity">
|
|
<div class="w-12 h-12 rounded-full bg-[var(--color-jellyfin)] flex items-center justify-center">
|
|
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Now Playing indicator -->
|
|
{#if isCurrent}
|
|
<div class="absolute top-2 left-2 px-2 py-1 bg-yellow-400 text-black rounded text-xs font-semibold">
|
|
Current
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Progress bar -->
|
|
{#if epProgress > 0}
|
|
<div class="absolute bottom-0 left-0 right-0 h-1 bg-gray-800">
|
|
<div
|
|
class="h-full bg-[var(--color-jellyfin)]"
|
|
style="width: {epProgress}%"
|
|
></div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Played indicator -->
|
|
{#if ep.userData?.isPlayed}
|
|
<div class="absolute top-2 right-2">
|
|
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Episode info -->
|
|
<div class="mt-2 space-y-1">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
|
|
{stripCardLabel(ep, episode)}
|
|
</span>
|
|
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
|
|
{ep.name}
|
|
</p>
|
|
</div>
|
|
{#if ep.overview}
|
|
<p class="text-gray-400 text-sm line-clamp-2">
|
|
{ep.overview}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Discovery content, strictly below the episode strip (ux-flows §5B.2:
|
|
hero → strip → cast → similar). TRACES: UR-048 | DR-062, DR-142 -->
|
|
{#if genres.length}
|
|
<GenreTags {genres} maxShow={6} itemKind="episode" />
|
|
{/if}
|
|
|
|
{#if people.length}
|
|
<CastSection {people} />
|
|
{/if}
|
|
|
|
<!-- "More Like This" on an episode means similar shows, so it keys off the
|
|
series. Skipped for a series-less episode, which has nothing to match on. -->
|
|
{#if series && (series.genres?.length || series.people?.length)}
|
|
<RelatedItemsSection
|
|
currentItemId={series.id}
|
|
itemKind="series"
|
|
genres={series.genres ?? undefined}
|
|
people={series.people ?? undefined}
|
|
limit={12}
|
|
/>
|
|
{/if}
|
|
</div>
|