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:
@@ -0,0 +1,167 @@
|
||||
// Pure navigation/grouping logic for the series detail page.
|
||||
//
|
||||
// Extracted from `/library/[id]/+page.svelte` so it can be unit-tested: the
|
||||
// series Play button used to resolve `$libraryItems[0]` — the first *season* by
|
||||
// SortName — and navigate to `/player/<seasonId>`, which the player route
|
||||
// bounced back to `/library/<seasonId>`. Play on a series therefore played
|
||||
// nothing and landed on the season-1 page.
|
||||
//
|
||||
// Note what is NOT here: *which* episode is current. That is domain policy and
|
||||
// lives in Rust (`repository_get_series_current_episode`); this module only
|
||||
// renders and routes around the answer.
|
||||
//
|
||||
// TRACES: UR-062 | DR-102, DR-103
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
export interface SeasonData {
|
||||
season: MediaItem;
|
||||
episodes: MediaItem[];
|
||||
}
|
||||
|
||||
/** Jellyfin files specials under season 0. */
|
||||
const SPECIALS_SEASON = 0;
|
||||
|
||||
/** Sort key for a season number: specials come after every numbered season. */
|
||||
function seasonRank(seasonNumber: number | null | undefined): number {
|
||||
if (seasonNumber == null) return Number.MAX_SAFE_INTEGER - 1;
|
||||
return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-page anchor for a season, so a season link scrolls the series' single
|
||||
* continuous episode list instead of opening a page of its own.
|
||||
*/
|
||||
export function seasonAnchorId(seasonNumber: number | null | undefined): string {
|
||||
return `season-${seasonNumber ?? 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a link naming a season should actually go: the series, anchored at that
|
||||
* season. Returns `null` when the season carries no `seriesId` (a deep link into
|
||||
* a stale cache), in which case the caller must keep rendering something rather
|
||||
* than strand the user.
|
||||
*/
|
||||
export function seasonRedirectTarget(season: MediaItem): string | null {
|
||||
if (!season.seriesId) return null;
|
||||
const seasonNumber = season.indexNumber ?? season.parentIndexNumber;
|
||||
return `/library/${season.seriesId}#${seasonAnchorId(seasonNumber)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an episode link should go: the episode in the context of its series
|
||||
* (ux-flows §5B.1 — an episode is never browsed as a bare Episode page).
|
||||
* Falls back to the bare item page only when the series is unknown.
|
||||
*/
|
||||
export function episodeFocusHref(episode: MediaItem): string {
|
||||
if (!episode.seriesId) return `/library/${episode.id}`;
|
||||
return `/library/${episode.seriesId}?episode=${episode.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the series hero button goes.
|
||||
*
|
||||
* The Episode Focus View, not the player: ux-flows §5B.5 makes Play on a
|
||||
* *container* navigation and Play on a *leaf* the commitment. Returns `null`
|
||||
* when there is no current episode (an empty series), so the caller can hide
|
||||
* the button rather than link nowhere.
|
||||
*/
|
||||
export function seriesPlayHref(seriesId: string, current: MediaItem | null): string | null {
|
||||
if (!current) return null;
|
||||
return `/library/${seriesId}?episode=${current.id}`;
|
||||
}
|
||||
|
||||
/** Fraction of an episode already watched, 0 when unknown. */
|
||||
function progressFraction(episode: MediaItem): number {
|
||||
const position = episode.userData?.playbackPositionMs ?? 0;
|
||||
if (!episode.durationMs || position <= 0) return 0;
|
||||
return position / episode.durationMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for the series hero button — it names the episode it will open, so the
|
||||
* viewer knows where the button leads before pressing it.
|
||||
*/
|
||||
export function seriesPlayLabel(current: MediaItem | null): string {
|
||||
if (!current) return "Play";
|
||||
|
||||
const fraction = progressFraction(current);
|
||||
const verb = fraction > 0.01 && fraction < 0.95 ? "Resume" : "Play";
|
||||
|
||||
if (current.parentIndexNumber == null || current.indexNumber == null) return verb;
|
||||
return `${verb} S${current.parentIndexNumber}E${current.indexNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a series' episodes under its season headers.
|
||||
*
|
||||
* The episodes arrive from Rust already in series order; this only decides which
|
||||
* header each one renders beneath, and synthesizes a header for any season the
|
||||
* server did not return one for (a flat series, or a season fetch that failed).
|
||||
* Seasons with no episodes are dropped — an empty accordion row is noise.
|
||||
*/
|
||||
export function groupEpisodesBySeason(
|
||||
seasons: MediaItem[],
|
||||
episodes: MediaItem[]
|
||||
): SeasonData[] {
|
||||
const headerFor = new Map<number, MediaItem>();
|
||||
for (const season of seasons) {
|
||||
const number = season.indexNumber ?? season.parentIndexNumber;
|
||||
if (number != null && !headerFor.has(number)) headerFor.set(number, season);
|
||||
}
|
||||
|
||||
const grouped = new Map<number, MediaItem[]>();
|
||||
for (const episode of episodes) {
|
||||
const number = episode.parentIndexNumber ?? 1;
|
||||
const bucket = grouped.get(number);
|
||||
if (bucket) bucket.push(episode);
|
||||
else grouped.set(number, [episode]);
|
||||
}
|
||||
|
||||
return [...grouped.entries()]
|
||||
.sort(([a], [b]) => seasonRank(a) - seasonRank(b))
|
||||
.map(([number, seasonEpisodes]) => ({
|
||||
season:
|
||||
headerFor.get(number) ??
|
||||
({
|
||||
...seasonEpisodes[0],
|
||||
id: `synthetic-season-${number}`,
|
||||
kind: "season",
|
||||
indexNumber: number,
|
||||
name: number === SPECIALS_SEASON ? "Specials" : `Season ${number}`,
|
||||
overview: null,
|
||||
} as MediaItem),
|
||||
episodes: seasonEpisodes,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which seasons start expanded.
|
||||
*
|
||||
* Only the one the viewer is in. A ten-season show otherwise renders every
|
||||
* episode of every season at once, burying the one episode they came for. A
|
||||
* `?episode=` deep link expands that episode's season as well, and a show with
|
||||
* no resolved current episode falls back to its first season so the page is
|
||||
* never entirely collapsed.
|
||||
*
|
||||
* Returns season ids (not numbers) so the caller can key state per section,
|
||||
* including the synthesized headers.
|
||||
*/
|
||||
export function initialExpandedSeasons(
|
||||
seasons: SeasonData[],
|
||||
currentEpisodeId: string | null | undefined,
|
||||
focusedEpisodeId?: string | null
|
||||
): Set<string> {
|
||||
if (seasons.length === 0) return new Set();
|
||||
|
||||
const expanded = new Set<string>();
|
||||
for (const id of [currentEpisodeId, focusedEpisodeId]) {
|
||||
if (!id) continue;
|
||||
const owner = seasons.find((s) => s.episodes.some((e) => e.id === id));
|
||||
if (owner) expanded.add(owner.season.id);
|
||||
}
|
||||
|
||||
// Nothing matched — open the first season rather than nothing at all.
|
||||
if (expanded.size === 0) expanded.add(seasons[0].season.id);
|
||||
|
||||
return expanded;
|
||||
}
|
||||
Reference in New Issue
Block a user