Files
jellytau/src/routes/library/tv/+page.svelte
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

195 lines
6.4 KiB
Svelte

<!--
The TV library — one page, three tabs.
Was three routes (`/library/tv`, `/library/tv/shows`, `/library/shows/genres`,
the last of which did not even share a prefix with the others). They are now
`?view=browse|all|genres` here; the old routes redirect.
TRACES: UR-007, UR-023, UR-034, UR-063 | DR-007, DR-038, DR-039, DR-103, DR-105
-->
<script lang="ts">
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateUp } from "$lib/utils/navigation";
import { library, currentLibrary } from "$lib/stores/library";
import { tv } from "$lib/stores/tv";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import LibraryViewTabs from "$lib/components/library/LibraryViewTabs.svelte";
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
import { seasonRedirectTarget, episodeFocusHref } from "$lib/components/library/seriesNavigation";
import { resolveLibraryView, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
import type { MediaItem } from "$lib/api/types";
const BASE_PATH = "/library/tv";
const view = $derived(resolveLibraryView($page.url.searchParams.get("view")));
const tabLabels: Record<LibraryView, string> = {
browse: "Browse",
all: "All Shows",
genres: "Genres",
};
const allShowsConfig = {
itemType: "Series" as const,
title: "TV Shows",
backPath: BASE_PATH,
searchPlaceholder: "Search shows...",
sortOptions: [
{ key: "SortName", label: "A-Z" },
{ key: "ProductionYear", label: "Year" },
{ key: "DateCreated", label: "Recently Added" },
{ key: "CommunityRating", label: "Rating" },
],
defaultSort: "SortName",
displayComponent: "grid" as const,
};
const genresConfig = {
itemTypes: ["Series" as const],
title: "TV Genres",
backPath: BASE_PATH,
genreIcon:
"M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
itemDisplayMode: "poster" as const,
searchPlaceholder: "Search genres...",
noItemsMessage: "No shows found in this genre",
};
async function load() {
if (!$currentLibrary) {
goto("/library");
return;
}
await tv.loadSections($currentLibrary.id);
}
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
onMount(async () => {
await load();
markLoaded();
});
$effect(() => {
checkServerReachability($isServerReachable);
});
function handleItemClick(item: MediaItem) {
switch (item.type) {
// A season lands on its series, anchored at that season (DR-103).
case "Season":
goto(seasonRedirectTarget(item) ?? `/library/${item.id}`);
break;
// An episode opens inside its series, never as a bare episode page and
// never straight into the player (ux-flows §5B.1, §5B.5).
case "Episode":
goto(episodeFocusHref(item));
break;
case "Series":
case "Folder":
goto(`/library/${item.id}`);
break;
default:
goto(`/player/${item.id}`);
break;
}
}
const heroItems = $derived($tv.heroItems);
const continueWatching = $derived($tv.continueWatching);
const nextUp = $derived($tv.nextUp);
const recentlyAdded = $derived($tv.recentlyAdded);
const genreRows = $derived($tv.genreRows);
const isLoading = $derived($tv.isLoading);
const hasContent = $derived(
heroItems.length > 0 ||
continueWatching.length > 0 ||
nextUp.length > 0 ||
recentlyAdded.length > 0
);
</script>
<div class="space-y-6 pb-8">
<!-- Header — one per page, shared by every tab -->
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
<button
onclick={() => { library.setCurrentLibrary(null); navigateUp("/library"); }}
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
title="Back to libraries"
aria-label="Back to libraries"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
<LibraryViewTabs basePath={BASE_PATH} active={view} labels={tabLabels} />
{#if view === "all"}
<div class="px-4">
<GenericMediaListPage config={allShowsConfig} showHeader={false} />
</div>
{:else if view === "genres"}
<div class="px-4">
<GenericGenreBrowser config={genresConfig} showHeader={false} />
</div>
{:else if isLoading}
<div class="flex justify-center items-center py-32">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
</div>
{:else}
<div class="space-y-8">
<!-- Hero Banner -->
{#if heroItems.length > 0}
<HeroBanner items={heroItems} />
{/if}
<!-- Continue Watching -->
{#if continueWatching.length > 0}
<Carousel
title="Continue Watching"
items={continueWatching}
onItemClick={handleItemClick}
/>
{/if}
<!-- Next Up -->
{#if nextUp.length > 0}
<Carousel title="Next Up" items={nextUp} onItemClick={handleItemClick} />
{/if}
<!-- Recently Added -->
{#if recentlyAdded.length > 0}
<Carousel
title="Recently Added"
items={recentlyAdded}
onItemClick={handleItemClick}
showAll={() => goto(libraryViewUrl(BASE_PATH, "all"))}
/>
{/if}
<!-- One slider per genre -->
{#each genreRows as row (row.id)}
<Carousel
title={row.name}
items={row.items}
onItemClick={handleItemClick}
showAll={() => goto(libraryViewUrl(BASE_PATH, "genres"))}
/>
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Start watching something to fill this page.</p>
{/if}
</div>
{/if}
</div>