Files
jellytau/src/lib/components/library/episodeStrip.ts
T
dtourolle 58f2506966
🏗️ 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
feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
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
2026-08-03 20:37:43 +02:00

111 lines
4.4 KiB
TypeScript

// 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, 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, 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`?
*
* Matches by id first. Falls back to season+episode number, but ONLY when both
* numbers are known on both sides — otherwise `undefined === undefined` would
* mark every number-less episode as the current one (the bug that made the
* whole strip look like the current episode).
*/
export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
if (ep.id === current.id) return true;
if (
ep.indexNumber == null || current.indexNumber == null ||
ep.parentIndexNumber == null || current.parentIndexNumber == null
) {
return false;
}
return (
ep.parentIndexNumber === current.parentIndexNumber &&
ep.indexNumber === current.indexNumber
);
}
/**
* 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, 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 pool = allEpisodes.slice().sort(compareSeriesOrder);
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
if (idx === -1) {
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 - 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}.`;
}