feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
Opening a series dumped the viewer at the top of season 1, and its Play button played nothing at all: it resolved `$libraryItems[0]` — the first *season* by SortName — and navigated to `/player/<seasonId>`, which the player route bounced straight back to `/library/<seasonId>`. The backend could already answer "where is this viewer in this show": `repository_get_next_up_episodes` has accepted a `series_id` since it was written and no caller had ever passed one. Backend (DR-101, DR-106) - `repository/series_progress.rs`: `pick_current_episode` — in progress, else Next Up, else first unwatched, else the premiere. The third rung is the offline path, where Next Up is always empty. `sort_series_order` puts specials (season 0) after the numbered seasons. - `repository_get_series_episodes` takes over the season fan-out and the flat-series fallback, which were domain knowledge living in the frontend. - `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a container, also zeroes resume). Offline it refuses rather than diverging state the next sync would undo. Frontend (DR-102, DR-103, DR-104, DR-107) - Seasons collapse; only the current one is expanded, and the current episode is badged and scrolled into view. - Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's focus view, where Play commits (ux-flows §5B.5). - Seasons are no longer a destination: `/library/<seasonId>` redirects to `/library/<seriesId>#season-N`, and every inbound link follows. - The "More Episodes" strip spans the whole series, so a season finale offers the next premiere instead of dead-ending (§5B.2). - Clear-history buttons on the series hero and each season header. Routes (DR-105) - `/library/tv` and `/library/movies` absorb their all-titles and genres pages as `?view=` tabs; the four legacy routes redirect. 6 video routes become 2, and `/library/shows/genres` stops being the odd one out. Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and `libraryView.ts` so it is unit-tested rather than buried in components. Spec: docs/specs/series-current-episode-navigation.md
This commit is contained in:
@@ -1,26 +1,56 @@
|
||||
<!-- 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 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, onEpisodeClick }: Props = $props();
|
||||
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 seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
|
||||
const seasonName = $derived(
|
||||
season.name || (seasonNumber ? `Season ${seasonNumber}` : "Unknown Season")
|
||||
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">
|
||||
<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 -->
|
||||
@@ -38,28 +68,60 @@
|
||||
<!-- Season info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-bold text-white">
|
||||
{seasonName}
|
||||
<!-- 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"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white flex items-center gap-2">
|
||||
<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">{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">
|
||||
<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}
|
||||
<p class="text-gray-400 text-sm mt-3 line-clamp-3">
|
||||
{#if season.overview && expanded}
|
||||
<p class="text-gray-400 text-sm mt-3 line-clamp-3 pl-7">
|
||||
{season.overview}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Download Season Button -->
|
||||
<div class="flex-shrink-0">
|
||||
<!-- Per-season actions -->
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<SeasonDownloadButton
|
||||
seasonId={season.id}
|
||||
seriesName={season.seriesName || ""}
|
||||
@@ -68,19 +130,29 @@
|
||||
{episodeCount}
|
||||
size="sm"
|
||||
/>
|
||||
<ClearHistoryButton
|
||||
itemId={season.id}
|
||||
itemName={seasonName}
|
||||
scope="season"
|
||||
size="sm"
|
||||
onCleared={onHistoryCleared}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episode list -->
|
||||
<div class="space-y-1 pl-2">
|
||||
{#each episodes as episode (episode.id)}
|
||||
<EpisodeRow
|
||||
{episode}
|
||||
focused={episode.id === focusedEpisodeId}
|
||||
onclick={() => onEpisodeClick?.(episode)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#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)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user