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,12 +1,20 @@
|
||||
// Pure logic for the "More Episodes" strip in EpisodeFocusView.
|
||||
//
|
||||
// Extracted from the component so it can be unit-tested: the strip must never
|
||||
// collapse to just the current episode while real siblings exist, and it must
|
||||
// not mistake number-less episodes for the current one.
|
||||
// collapse to just the current episode while real siblings exist, must not
|
||||
// mistake number-less episodes for the current one, and must run past a season
|
||||
// boundary rather than dead-ending at the end of a season (ux-flows §5B.2).
|
||||
//
|
||||
// TRACES: UR-048 | DR-062
|
||||
// TRACES: UR-048, UR-062 | DR-062, DR-104
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
|
||||
/** Episodes shown before / after the current one in the strip window. */
|
||||
const BEFORE = 3;
|
||||
const AFTER = 6;
|
||||
|
||||
/** Jellyfin puts specials in season 0; they air outside the numbered run. */
|
||||
const SPECIALS_SEASON = 0;
|
||||
|
||||
/**
|
||||
* Does `ep` refer to the same episode as `current`?
|
||||
*
|
||||
@@ -29,33 +37,74 @@ export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort key for a season: specials (season 0) come *after* every numbered
|
||||
* season, matching how a viewer works through a show — S1, S2, …, then the
|
||||
* extras — rather than opening on a special because 0 < 1.
|
||||
*/
|
||||
function seasonRank(seasonNumber: number | null | undefined): number {
|
||||
return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast order across a whole series: season ascending, then episode.
|
||||
*
|
||||
* When *either* side's season is unknown there is no season axis to compare on,
|
||||
* so it falls through to episode number. That makes the comparator technically
|
||||
* non-transitive across such a mix, which is safe here because only the
|
||||
* directly-fetched `current` episode can lack a season and it is never part of
|
||||
* the array being sorted — it is only positioned against it (see
|
||||
* `adjacentEpisodes`).
|
||||
*/
|
||||
export function compareSeriesOrder(a: MediaItem, b: MediaItem): number {
|
||||
if (a.parentIndexNumber != null && b.parentIndexNumber != null) {
|
||||
const bySeason = seasonRank(a.parentIndexNumber) - seasonRank(b.parentIndexNumber);
|
||||
if (bySeason !== 0) return bySeason;
|
||||
}
|
||||
return (a.indexNumber ?? 0) - (b.indexNumber ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window of episodes shown under the hero: up to 3 before and 6 after the
|
||||
* current episode. Degrades gracefully:
|
||||
* - prefers the current season, falling back to the full list when the season
|
||||
* is unknown (e.g. the episode was fetched directly on an API-ID mismatch);
|
||||
* - splices the current episode into the pool at its numeric position when it
|
||||
* isn't present, so it still anchors the window;
|
||||
* current episode, in series order across *all* seasons.
|
||||
*
|
||||
* Crossing a season boundary is the point (ux-flows §5B.2): finishing a season
|
||||
* finale should offer the next season's premiere, not an empty strip. Degrades
|
||||
* gracefully:
|
||||
* - splices the current episode into the pool at its ordered position when it
|
||||
* isn't present (an API id mismatch on a directly-fetched episode), so it
|
||||
* still anchors the window;
|
||||
* - returns just `[current]` only when there genuinely are no other episodes.
|
||||
*/
|
||||
export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] {
|
||||
const seasonMatches = allEpisodes.filter(
|
||||
(e) => current.parentIndexNumber != null && e.parentIndexNumber === current.parentIndexNumber
|
||||
);
|
||||
const pool = (seasonMatches.length > 0 ? seasonMatches : allEpisodes)
|
||||
.slice()
|
||||
.sort((a, b) => (a.indexNumber ?? 0) - (b.indexNumber ?? 0));
|
||||
const pool = allEpisodes.slice().sort(compareSeriesOrder);
|
||||
|
||||
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
|
||||
|
||||
if (idx === -1) {
|
||||
const epNum = current.indexNumber ?? 0;
|
||||
const insertAt = pool.findIndex((e) => (e.indexNumber ?? 0) > epNum);
|
||||
const insertAt = pool.findIndex((e) => compareSeriesOrder(e, current) > 0);
|
||||
idx = insertAt === -1 ? pool.length : insertAt;
|
||||
pool.splice(idx, 0, current);
|
||||
}
|
||||
|
||||
const start = Math.max(0, idx - 3);
|
||||
const end = Math.min(pool.length, idx + 7);
|
||||
const start = Math.max(0, idx - BEFORE);
|
||||
const end = Math.min(pool.length, idx + AFTER + 1);
|
||||
return pool.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for a strip card, relative to the episode in focus.
|
||||
*
|
||||
* Within the current season a bare number reads cleanly ("6."). Once the window
|
||||
* crosses into another season that number is ambiguous, so the card names the
|
||||
* season too ("S3E1") — otherwise the premiere after a finale just reads "1."
|
||||
*/
|
||||
export function stripCardLabel(ep: MediaItem, current: MediaItem): string {
|
||||
const crossesSeason =
|
||||
ep.parentIndexNumber != null &&
|
||||
current.parentIndexNumber != null &&
|
||||
ep.parentIndexNumber !== current.parentIndexNumber;
|
||||
|
||||
if (crossesSeason) return `S${ep.parentIndexNumber}E${ep.indexNumber ?? 0}`;
|
||||
return `${ep.indexNumber ?? 0}.`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user