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,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveLibraryView,
|
||||
libraryViewUrl,
|
||||
LIBRARY_VIEWS,
|
||||
DEFAULT_LIBRARY_VIEW,
|
||||
} from "./libraryView";
|
||||
|
||||
describe("resolveLibraryView", () => {
|
||||
it("resolves each known view", () => {
|
||||
for (const view of LIBRARY_VIEWS) {
|
||||
expect(resolveLibraryView(view)).toBe(view);
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to browse when the param is absent", () => {
|
||||
expect(resolveLibraryView(null)).toBe("browse");
|
||||
expect(resolveLibraryView(undefined)).toBe("browse");
|
||||
});
|
||||
|
||||
it("falls back to the default rather than rendering nothing for junk", () => {
|
||||
expect(resolveLibraryView("shows")).toBe(DEFAULT_LIBRARY_VIEW);
|
||||
expect(resolveLibraryView("")).toBe(DEFAULT_LIBRARY_VIEW);
|
||||
});
|
||||
|
||||
it("tolerates case and surrounding whitespace", () => {
|
||||
expect(resolveLibraryView("Genres")).toBe("genres");
|
||||
expect(resolveLibraryView(" all ")).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("libraryViewUrl", () => {
|
||||
it("omits the param for the default view so the landing URL stays clean", () => {
|
||||
expect(libraryViewUrl("/library/tv", "browse")).toBe("/library/tv");
|
||||
});
|
||||
|
||||
it("names the non-default views", () => {
|
||||
expect(libraryViewUrl("/library/tv", "all")).toBe("/library/tv?view=all");
|
||||
expect(libraryViewUrl("/library/movies", "genres")).toBe("/library/movies?view=genres");
|
||||
});
|
||||
|
||||
it("round-trips through resolveLibraryView", () => {
|
||||
for (const view of LIBRARY_VIEWS) {
|
||||
const url = libraryViewUrl("/library/tv", view);
|
||||
const param = new URL(url, "http://x").searchParams.get("view");
|
||||
expect(resolveLibraryView(param)).toBe(view);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
// Which section of a video library page is showing.
|
||||
//
|
||||
// Browse / All / Genres used to be three routes per library, named
|
||||
// inconsistently across the two libraries (`/library/tv/shows` vs
|
||||
// `/library/movies/all`; `/library/shows/genres` vs `/library/movies/genres`).
|
||||
// They are now one route with tabs, and this is the pure `?view=` ↔ tab
|
||||
// mapping.
|
||||
//
|
||||
// TRACES: UR-063 | DR-105
|
||||
export type LibraryView = "browse" | "all" | "genres";
|
||||
|
||||
/** Tab order, left to right. `browse` leads because it is the landing view. */
|
||||
export const LIBRARY_VIEWS: readonly LibraryView[] = ["browse", "all", "genres"];
|
||||
|
||||
/** The view a page shows when `?view=` is absent or unrecognised. */
|
||||
export const DEFAULT_LIBRARY_VIEW: LibraryView = "browse";
|
||||
|
||||
/**
|
||||
* Read a `?view=` value. Anything unknown — a typo, a stale bookmark, a
|
||||
* removed tab — lands on the default rather than rendering nothing.
|
||||
*/
|
||||
export function resolveLibraryView(value: string | null | undefined): LibraryView {
|
||||
if (value == null) return DEFAULT_LIBRARY_VIEW;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return (LIBRARY_VIEWS as readonly string[]).includes(normalized)
|
||||
? (normalized as LibraryView)
|
||||
: DEFAULT_LIBRARY_VIEW;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL for a tab. The default view omits the param, so the landing URL stays
|
||||
* `/library/tv` — the same convention `searchRouteUrl` uses for the `all` scope.
|
||||
*/
|
||||
export function libraryViewUrl(basePath: string, view: LibraryView): string {
|
||||
return view === DEFAULT_LIBRARY_VIEW ? basePath : `${basePath}?view=${view}`;
|
||||
}
|
||||
@@ -42,7 +42,9 @@ export function resolveSearchScope(pathname: string): SearchScope {
|
||||
if (path === "/library/music" || path.startsWith("/library/music/")) return "music";
|
||||
if (path === "/library/movies" || path.startsWith("/library/movies/")) return "movies";
|
||||
if (path === "/library/tv" || path.startsWith("/library/tv/")) return "tv";
|
||||
// `/library/shows/genres` is the TV genre route despite the differing segment.
|
||||
// `/library/shows/*` is a legacy TV route that now redirects into
|
||||
// `/library/tv?view=genres` (DR-105). Kept so a search typed on the URL
|
||||
// before the redirect lands still scopes to TV.
|
||||
if (path === "/library/shows" || path.startsWith("/library/shows/")) return "tv";
|
||||
|
||||
return "all";
|
||||
|
||||
Reference in New Issue
Block a user