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

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:
2026-08-03 20:37:43 +02:00
parent a818fee297
commit 58f2506966
47 changed files with 3178 additions and 1130 deletions
+40
View File
@@ -1267,6 +1267,46 @@ async repositoryGetResumeItems(handle: string, parentId: string | null, limit: n
async repositoryGetNextUpEpisodes(handle: string, seriesId: string | null, limit: number | null) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_next_up_episodes", { handle, seriesId, limit });
},
/**
* Every episode of a series, across all seasons, in series order.
*
* Jellyfin hangs episodes off season folders — except for "flat" series whose
* children are episodes directly. Both shapes are provider vocabulary, so the
* fan-out and its fallback live in Rust rather than being reimplemented in the
* frontend (which is what it used to do).
*
* TRACES: UR-062 | DR-101
*/
async repositoryGetSeriesEpisodes(handle: string, seriesId: string) : Promise<MediaItem[]> {
return await TAURI_INVOKE("repository_get_series_episodes", { handle, seriesId });
},
/**
* The episode a viewer should land on when they open a series.
*
* "Current" is domain policy, not layout: an episode in progress, else the
* server's Next Up for the series, else the first unwatched episode, else the
* first. The third rung is what makes this work offline, where Next Up is
* always empty. Returns `None` only when the series has no episodes at all.
*
* TRACES: UR-062 | DR-101
*/
async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise<MediaItem | null> {
return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId });
},
/**
* Erase the viewer's watch history for an item.
*
* Clears the played flag and the resume position; on a series or season the
* server applies it to everything inside. A series cleared this way is "never
* watched" again, so `repository_get_series_current_episode` returns its
* premiere. Requires the server — offline this fails rather than diverging
* local state the next sync would overwrite.
*
* TRACES: UR-064 | DR-106
*/
async repositoryClearWatchHistory(handle: string, itemId: string) : Promise<null> {
return await TAURI_INVOKE("repository_clear_watch_history", { handle, itemId });
},
/**
* Get recently played audio
*/
+31
View File
@@ -137,6 +137,37 @@ export class RepositoryClient {
return commands.repositoryGetNextUpEpisodes(this.ensureHandle(), seriesId ?? null, limit ?? null);
}
/**
* Every episode of a series, across all seasons, already in series order.
* The backend owns the season fan-out and the flat-series fallback.
*
* TRACES: UR-062 | DR-101
*/
async getSeriesEpisodes(seriesId: string): Promise<MediaItem[]> {
return commands.repositoryGetSeriesEpisodes(this.ensureHandle(), seriesId);
}
/**
* The episode the viewer should land on when opening this series. `null` only
* when the series has no episodes.
*
* TRACES: UR-062 | DR-101
*/
async getSeriesCurrentEpisode(seriesId: string): Promise<MediaItem | null> {
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
}
/**
* Erase watch history for an item. On a series or season the server applies
* it to everything inside, so the container returns to "never watched".
* Requires the server — this fails offline rather than diverging local state.
*
* TRACES: UR-064 | DR-106
*/
async clearWatchHistory(itemId: string): Promise<void> {
await commands.repositoryClearWatchHistory(this.ensureHandle(), itemId);
}
async getRecentlyPlayedAudio(limit?: number): Promise<MediaItem[]> {
return commands.repositoryGetRecentlyPlayedAudio(this.ensureHandle(), limit ?? null);
}
@@ -14,6 +14,7 @@
import { goto } from "$app/navigation";
import type { Library, MediaItem } from "$lib/api/types";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
import { seasonRedirectTarget, episodeFocusHref } from "$lib/components/library/seriesNavigation";
import { formatBytes } from "$lib/utils/formatBytes";
import {
downloadedCatalog,
@@ -62,6 +63,16 @@
void openLibrary(item as Library);
return;
}
// Seasons and episodes resolve inside their series (DR-103): a season has
// no page of its own and an episode is never browsed bare.
if (item.kind === "season") {
goto(seasonRedirectTarget(item) ?? `/library/${item.id}`);
return;
}
if (item.kind === "episode") {
goto(episodeFocusHref(item));
return;
}
goto(`/library/${item.id}`);
}
@@ -0,0 +1,94 @@
<!--
Erase watch history for a series or a season.
The backend does the work (`repository_clear_watch_history` → Jellyfin's
mark-unplayed, which is recursive over a container and also zeroes resume
positions); this only confirms the intent and reports the outcome. Clearing a
series returns it to "never watched", so it reopens on S1E1.
TRACES: UR-064 | DR-106
-->
<script lang="ts">
import { auth } from "$lib/stores/auth";
import { isServerReachable } from "$lib/stores/connectivity";
interface Props {
/** Series or season id to clear. */
itemId: string;
/** Name shown in the confirm prompt. */
itemName: string;
/** What is being cleared, for the prompt wording. */
scope: "series" | "season";
size?: "sm" | "lg";
/** Called after a successful clear so the caller can reload. */
onCleared?: () => void;
}
let { itemId, itemName, scope, size = "lg", onCleared }: Props = $props();
let busy = $state(false);
const label = $derived(scope === "series" ? "Clear history" : "Clear season history");
async function handleClick() {
if (busy) return;
const subject = scope === "series" ? `all of “${itemName}”` : `“${itemName}”`;
// Destructive and not undoable — always ask, even though the server keeps
// no undo of its own.
if (
!confirm(
`Erase watch history for ${subject}?\n\n` +
"Every episode is marked unwatched and resume positions are cleared. " +
"This cannot be undone."
)
) {
return;
}
busy = true;
try {
await auth.getRepository().clearWatchHistory(itemId);
onCleared?.();
} catch (e) {
console.error("Failed to clear watch history:", e);
alert(
`Could not clear watch history: ${e instanceof Error ? e.message : String(e)}`
);
} finally {
busy = false;
}
}
</script>
<button
onclick={handleClick}
disabled={busy || !$isServerReachable}
title={$isServerReachable
? "Mark everything unwatched and clear resume positions"
: "Needs a connection to the server"}
class="rounded-lg font-medium flex items-center gap-2 transition-colors
bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)]
disabled:opacity-40 disabled:cursor-not-allowed
{size === 'lg' ? 'px-6 py-2' : 'px-3 py-1.5 text-sm'}"
>
{#if busy}
<div
class="border-2 border-current border-t-transparent rounded-full animate-spin
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
></div>
{:else}
<svg
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6a7 7 0 1 1 7 7c-1.93
0-3.68-.79-4.94-2.06l-1.42 1.42A8.95 8.95 0 0 0 13 21a9 9 0 0 0
0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"
/>
</svg>
{/if}
{busy ? "Clearing…" : label}
</button>
@@ -4,7 +4,11 @@
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { isCurrentEpisode as isSameEpisode, adjacentEpisodes as computeAdjacent } from "./episodeStrip";
import {
isCurrentEpisode as isSameEpisode,
adjacentEpisodes as computeAdjacent,
stripCardLabel,
} from "./episodeStrip";
interface Props {
episode: MediaItem;
@@ -245,8 +249,8 @@
<!-- Episode info -->
<div class="mt-2 space-y-1">
<div class="flex items-center gap-2">
<span class="text-[var(--color-jellyfin)] text-sm font-semibold">
{ep.indexNumber || 0}.
<span class="text-[var(--color-jellyfin)] text-sm font-semibold whitespace-nowrap">
{stripCardLabel(ep, episode)}
</span>
<p class="text-white font-medium truncate {isCurrent ? 'text-yellow-400' : 'group-hover/card:text-[var(--color-jellyfin)]'} transition-colors">
{ep.name}
+20 -3
View File
@@ -10,15 +10,21 @@
interface Props {
episode: MediaItem;
focused?: boolean;
/**
* This is the episode the viewer is up to. Marked and scrolled to when the
* series page opens, so a viewer four seasons deep lands on their place
* instead of the top of season 1. TRACES: UR-062 | DR-102
*/
current?: boolean;
onclick?: () => void;
}
let { episode, focused = false, onclick }: Props = $props();
let { episode, focused = false, current = false, onclick }: Props = $props();
let buttonRef: HTMLButtonElement | null = null;
onMount(() => {
if (focused && buttonRef) {
if ((focused || current) && buttonRef) {
// Scroll into view with some offset from top
setTimeout(() => {
buttonRef?.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -51,7 +57,11 @@
<button
bind:this={buttonRef}
type="button"
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused ? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]' : ''}"
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
: current
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
: ''}"
{onclick}
>
<!-- Thumbnail -->
@@ -137,6 +147,13 @@
<h3 class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors">
{truncateMiddle(episode.name, 56)}
</h3>
{#if current}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
>
Up next
</span>
{/if}
<!-- Played indicator -->
{#if episode.userData?.isPlayed}
<svg class="w-4 h-4 flex-shrink-0 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
@@ -34,9 +34,16 @@
interface Props {
config: GenreConfig;
/**
* Suppress the back button + title when this renders as a *tab* of a
* library page that already has a header. Drilling into a single genre
* still shows the header — there the back button is the way out.
* TRACES: UR-063 | DR-105
*/
showHeader?: boolean;
}
let { config }: Props = $props();
let { config, showHeader = true }: Props = $props();
let genres = $state<Genre[]>([]);
let filteredGenres = $state<Genre[]>([]);
@@ -153,17 +160,20 @@
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-4">
<BackButton onClick={goBack} label="Back" />
<h1 class="text-3xl font-bold text-white">
{#if selectedGenre}
{selectedGenre.name}
{:else}
{config.title}
{/if}
</h1>
</div>
<!-- Header. Inside a genre the back button is the only way out, so it shows
even when the host page suppresses the top-level header. -->
{#if showHeader || selectedGenre}
<div class="flex items-center gap-4">
<BackButton onClick={goBack} label="Back" />
<h1 class="text-3xl font-bold text-white">
{#if selectedGenre}
{selectedGenre.name}
{:else}
{config.title}
{/if}
</h1>
</div>
{/if}
{#if !selectedGenre}
<!-- Genre Browser -->
@@ -41,9 +41,15 @@
interface Props {
config: MediaListConfig;
/**
* Suppress the back button + title. Set when this renders as a *tab* of a
* library page, which already has its own header — two stacked headers and
* two back buttons read as two pages. TRACES: UR-063 | DR-105
*/
showHeader?: boolean;
}
let { config }: Props = $props();
let { config, showHeader = true }: Props = $props();
let items = $state<MediaItem[]>([]);
let loading = $state(true);
@@ -246,10 +252,12 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-4">
<BackButton onClick={goBack} label="Back" />
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
</div>
{#if showHeader}
<div class="flex items-center gap-4">
<BackButton onClick={goBack} label="Back" />
<h1 class="text-3xl font-bold text-white">{config.title}</h1>
</div>
{/if}
<!-- Search and Sort Bar -->
<div class="flex flex-col sm:flex-row gap-4">
+7 -4
View File
@@ -2,6 +2,7 @@
import { goto } from "$app/navigation";
import type { MediaKind } from "$lib/api/types";
import { libraryViewUrl } from "$lib/utils/libraryView";
interface Props {
genres: string[];
@@ -17,7 +18,9 @@
itemKind
}: Props = $props();
// Map the item kind to its genre-browse route
// Map the item kind to its genre-browse surface. Video genres are a tab of
// the library page now, not a route of their own (DR-105); linking straight
// to the tab avoids a redirect hop through the legacy paths.
function genreBasePath(kind: MediaKind | undefined): string {
switch (kind) {
case "album":
@@ -28,11 +31,11 @@
case "series":
case "season":
case "episode":
return "/library/shows/genres";
return libraryViewUrl("/library/tv", "genres");
case "movie":
return "/library/movies/genres";
return libraryViewUrl("/library/movies", "genres");
default:
return "/library/movies/genres";
return libraryViewUrl("/library/movies", "genres");
}
}
@@ -0,0 +1,44 @@
<!--
Browse / All / Genres for a video library.
These were three routes per library with names that did not agree across the
two libraries; they are now tabs on one route, driven by `?view=` so a tab is
linkable and survives a back navigation.
TRACES: UR-063 | DR-105
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { LIBRARY_VIEWS, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
interface Props {
/** Route the tabs live on, e.g. `/library/tv`. */
basePath: string;
active: LibraryView;
/** Per-view labels — "All Shows" vs "All Movies". */
labels: Record<LibraryView, string>;
}
let { basePath, active, labels }: Props = $props();
function select(view: LibraryView) {
if (view === active) return;
// replaceState: switching tabs is not a navigation step worth a back press.
goto(libraryViewUrl(basePath, view), { replaceState: true, noScroll: true });
}
</script>
<nav class="flex items-center gap-1 px-4" aria-label="Library sections">
{#each LIBRARY_VIEWS as view (view)}
<button
onclick={() => select(view)}
aria-current={view === active ? "page" : undefined}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors
{view === active
? 'bg-[var(--color-jellyfin)] text-white'
: 'text-gray-400 hover:text-white hover:bg-white/10'}"
>
{labels[view]}
</button>
{/each}
</nav>
+94 -22
View File
@@ -1,26 +1,56 @@
<!-- TRACES: UR-062, UR-064 | DR-102, DR-103, DR-106, DR-107 -->
<script lang="ts">
import type { MediaItem } from "$lib/api/types";
import EpisodeRow from "./EpisodeRow.svelte";
import SeasonDownloadButton from "./SeasonDownloadButton.svelte";
import ClearHistoryButton from "./ClearHistoryButton.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { seasonAnchorId } from "./seriesNavigation";
interface Props {
season: MediaItem;
episodes: MediaItem[];
focusedEpisodeId?: string;
/** The episode the viewer is up to — highlighted and scrolled into view. */
currentEpisodeId?: string;
/**
* Whether this season's episode list is open. Only the current season
* starts expanded, so a ten-season show does not render every episode at
* once. TRACES: UR-062 | DR-107
*/
expanded?: boolean;
onToggle?: () => void;
onEpisodeClick?: (episode: MediaItem) => void;
onHistoryCleared?: () => void;
}
let { season, episodes, focusedEpisodeId, onEpisodeClick }: Props = $props();
let {
season,
episodes,
focusedEpisodeId,
currentEpisodeId,
expanded = false,
onToggle,
onEpisodeClick,
onHistoryCleared,
}: Props = $props();
const holdsCurrentEpisode = $derived(
currentEpisodeId != null && episodes.some((e) => e.id === currentEpisodeId)
);
const watchedCount = $derived(episodes.filter((e) => e.userData?.isPlayed).length);
const episodeCount = $derived(episodes.length);
const seasonNumber = $derived(season.indexNumber || season.parentIndexNumber);
const seasonNumber = $derived(season.indexNumber ?? season.parentIndexNumber);
const seasonName = $derived(
season.name || (seasonNumber ? `Season ${seasonNumber}` : "Unknown Season")
season.name || (seasonNumber != null ? `Season ${seasonNumber}` : "Unknown Season")
);
// Seasons have no page of their own; a season link scrolls to this anchor
// inside the series' single continuous episode list.
const anchor = $derived(seasonAnchorId(seasonNumber));
</script>
<section class="space-y-4">
<section class="space-y-4 scroll-mt-4" id={anchor}>
<!-- Season header -->
<div class="flex gap-4 p-4 bg-[var(--color-surface)] rounded-xl">
<!-- Season poster -->
@@ -38,28 +68,60 @@
<!-- Season info -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<h2 class="text-xl font-bold text-white">
{seasonName}
<!-- The whole title block toggles the season open/closed. -->
<button
type="button"
onclick={onToggle}
aria-expanded={expanded}
aria-controls="{anchor}-episodes"
class="flex-1 min-w-0 text-left group/season"
>
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<svg
class="w-5 h-5 flex-shrink-0 text-gray-400 transition-transform duration-200
group-hover/season:text-white {expanded ? 'rotate-90' : ''}"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span class="truncate">{seasonName}</span>
{#if holdsCurrentEpisode}
<span
class="flex-shrink-0 px-2 py-0.5 rounded bg-yellow-400 text-black text-xs font-semibold"
>
Up next
</span>
{/if}
</h2>
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400">
<div class="flex items-center gap-3 mt-1 text-sm text-gray-400 pl-7">
<span>{episodeCount} {episodeCount === 1 ? "Episode" : "Episodes"}</span>
<!-- Collapsed, this is the only progress signal the season shows. -->
{#if watchedCount > 0}
<span></span>
<span>
{watchedCount === episodeCount ? "Watched" : `${watchedCount} watched`}
</span>
{/if}
{#if season.productionYear}
<span></span>
<span>{season.productionYear}</span>
{/if}
</div>
{#if season.overview}
<p class="text-gray-400 text-sm mt-3 line-clamp-3">
{#if season.overview && expanded}
<p class="text-gray-400 text-sm mt-3 line-clamp-3 pl-7">
{season.overview}
</p>
{/if}
</div>
</button>
<!-- Download Season Button -->
<div class="flex-shrink-0">
<!-- Per-season actions -->
<div class="flex-shrink-0 flex items-center gap-2">
<SeasonDownloadButton
seasonId={season.id}
seriesName={season.seriesName || ""}
@@ -68,19 +130,29 @@
{episodeCount}
size="sm"
/>
<ClearHistoryButton
itemId={season.id}
itemName={seasonName}
scope="season"
size="sm"
onCleared={onHistoryCleared}
/>
</div>
</div>
</div>
</div>
<!-- Episode list -->
<div class="space-y-1 pl-2">
{#each episodes as episode (episode.id)}
<EpisodeRow
{episode}
focused={episode.id === focusedEpisodeId}
onclick={() => onEpisodeClick?.(episode)}
/>
{/each}
</div>
{#if expanded}
<div class="space-y-1 pl-2" id="{anchor}-episodes">
{#each episodes as episode (episode.id)}
<EpisodeRow
{episode}
focused={episode.id === focusedEpisodeId}
current={episode.id === currentEpisodeId}
onclick={() => onEpisodeClick?.(episode)}
/>
{/each}
</div>
{/if}
</section>
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip";
import { isCurrentEpisode, adjacentEpisodes, compareSeriesOrder, stripCardLabel } from "./episodeStrip";
// Minimal episode factory — only the fields the strip logic reads.
function ep(
@@ -71,11 +71,41 @@ describe("adjacentEpisodes", () => {
expect(strip).toContain(current);
});
it("restricts to the current season when multiple seasons are present", () => {
// ux-flows §5B.2, "Cross-season continuity": the window spans the whole
// series in episode order, so it runs past a season boundary rather than
// dead-ending at the end of a season.
it("runs past the end of a season into the next one", () => {
const eps = [...season(1, 5), ...season(2, 5)];
const current = eps[6]; // S2E2
const current = eps[4]; // S1E5 — the season finale
const strip = adjacentEpisodes(current, eps);
expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true);
expect(strip.map((e) => e.id)).toEqual([
"s1e2", "s1e3", "s1e4", "s1e5",
"s2e1", "s2e2", "s2e3", "s2e4", "s2e5",
]);
});
it("reaches back into the previous season from a season opener", () => {
const eps = [...season(1, 5), ...season(2, 5)];
const current = eps[5]; // S2E1
const strip = adjacentEpisodes(current, eps);
expect(strip.slice(0, 3).map((e) => e.id)).toEqual(["s1e3", "s1e4", "s1e5"]);
expect(strip[3].id).toBe("s2e1");
});
it("orders by season then episode, never interleaving seasons", () => {
const eps = [...season(2, 3), ...season(1, 3)]; // deliberately out of order
const current = eps[3]; // S1E1
const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual([
"s1e1", "s1e2", "s1e3", "s2e1", "s2e2", "s2e3",
]);
});
it("sorts specials (season 0) after the numbered seasons", () => {
const eps = [...season(0, 2), ...season(1, 2)];
const current = eps[2]; // S1E1
const strip = adjacentEpisodes(current, eps);
expect(strip.map((e) => e.id)).toEqual(["s1e1", "s1e2", "s0e1", "s0e2"]);
});
it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => {
@@ -93,5 +123,42 @@ describe("adjacentEpisodes", () => {
const current = ep("mystery", null, 3); // no season number
const strip = adjacentEpisodes(current, eps);
expect(strip.length).toBeGreaterThan(1);
// Anchored at its episode number, not dumped at one end of the list.
expect(strip.indexOf(current)).toBeGreaterThan(0);
expect(strip.indexOf(current)).toBeLessThan(strip.length - 1);
});
});
describe("compareSeriesOrder", () => {
it("orders by season, then episode", () => {
expect(compareSeriesOrder(ep("a", 1, 9), ep("b", 2, 1))).toBeLessThan(0);
expect(compareSeriesOrder(ep("a", 2, 1), ep("b", 2, 2))).toBeLessThan(0);
expect(compareSeriesOrder(ep("a", 2, 2), ep("b", 2, 2))).toBe(0);
});
it("puts specials last", () => {
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 1, 1))).toBeGreaterThan(0);
expect(compareSeriesOrder(ep("a", 0, 1), ep("b", 9, 1))).toBeGreaterThan(0);
});
it("falls back to episode number when a season is unknown", () => {
expect(compareSeriesOrder(ep("a", null, 2), ep("b", 1, 5))).toBeLessThan(0);
});
});
describe("stripCardLabel", () => {
const current = ep("cur", 2, 4);
it("shows a bare episode number within the current season", () => {
expect(stripCardLabel(ep("a", 2, 6), current)).toBe("6.");
});
it("shows SxEy once the card crosses a season boundary", () => {
expect(stripCardLabel(ep("a", 3, 1), current)).toBe("S3E1");
expect(stripCardLabel(ep("a", 1, 8), current)).toBe("S1E8");
});
it("degrades to the episode number when the season is unknown", () => {
expect(stripCardLabel(ep("a", null, 7), current)).toBe("7.");
});
});
+67 -18
View File
@@ -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}.`;
}
@@ -0,0 +1,197 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import {
seasonAnchorId,
seasonRedirectTarget,
episodeFocusHref,
seriesPlayHref,
seriesPlayLabel,
groupEpisodesBySeason,
initialExpandedSeasons,
} from "./seriesNavigation";
const SERIES = "series-1";
function ep(id: string, season: number | null, number: number | null): MediaItem {
return {
id,
name: `S${season}E${number}`,
kind: "episode",
seriesId: SERIES,
parentIndexNumber: season,
indexNumber: number,
durationMs: 1_000_000,
} as unknown as MediaItem;
}
function seasonHeader(number: number, id = `season-${number}`): MediaItem {
return {
id,
name: `Season ${number}`,
kind: "season",
seriesId: SERIES,
indexNumber: number,
} as unknown as MediaItem;
}
function withProgress(episode: MediaItem, fraction: number): MediaItem {
return {
...episode,
userData: { playbackPositionMs: (episode.durationMs ?? 0) * fraction },
} as MediaItem;
}
describe("seriesPlayHref", () => {
// The reported bug: Play resolved the first *season* child and navigated to
// /player/<seasonId>, which bounced back to the season-1 page.
it("opens the current episode's focus view, never a season or the player", () => {
const href = seriesPlayHref(SERIES, ep("s2e4", 2, 4));
expect(href).toBe("/library/series-1?episode=s2e4");
expect(href).not.toContain("/player/");
});
it("returns null for a series with no episodes so the button can hide", () => {
expect(seriesPlayHref(SERIES, null)).toBeNull();
});
});
describe("seriesPlayLabel", () => {
it("names the episode it will open", () => {
expect(seriesPlayLabel(ep("s2e4", 2, 4))).toBe("Play S2E4");
});
it("says Resume for a part-watched episode", () => {
expect(seriesPlayLabel(withProgress(ep("s2e4", 2, 4), 0.4))).toBe("Resume S2E4");
});
it("says Play for a barely-started or nearly-finished episode", () => {
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.001))).toBe("Play S1E1");
expect(seriesPlayLabel(withProgress(ep("s1e1", 1, 1), 0.99))).toBe("Play S1E1");
});
it("degrades to a bare verb when the numbering is unknown", () => {
expect(seriesPlayLabel(ep("x", null, null))).toBe("Play");
expect(seriesPlayLabel(null)).toBe("Play");
});
});
describe("seasonRedirectTarget", () => {
it("sends a season to its series, anchored at that season", () => {
expect(seasonRedirectTarget(seasonHeader(3))).toBe("/library/series-1#season-3");
});
it("returns null when the series is unknown, so the caller can fall back", () => {
const orphan = { ...seasonHeader(3), seriesId: undefined } as MediaItem;
expect(seasonRedirectTarget(orphan)).toBeNull();
});
it("matches the anchor the season section renders", () => {
expect(seasonRedirectTarget(seasonHeader(2))).toBe(
`/library/${SERIES}#${seasonAnchorId(2)}`
);
});
});
describe("episodeFocusHref", () => {
it("opens an episode inside its series (never a bare episode page)", () => {
expect(episodeFocusHref(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2");
});
it("falls back to the bare item page when the series is unknown", () => {
const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem;
expect(episodeFocusHref(orphan)).toBe("/library/lone");
});
});
describe("groupEpisodesBySeason", () => {
it("groups episodes under their season headers, in season order", () => {
const seasons = [seasonHeader(2), seasonHeader(1)];
const episodes = [ep("s1e1", 1, 1), ep("s1e2", 1, 2), ep("s2e1", 2, 1)];
const grouped = groupEpisodesBySeason(seasons, episodes);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 2]);
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["s1e1", "s1e2"]);
expect(grouped[1].episodes.map((e) => e.id)).toEqual(["s2e1"]);
});
it("puts specials after the numbered seasons", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(0), seasonHeader(1)],
[ep("s0e1", 0, 1), ep("s1e1", 1, 1)]
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([1, 0]);
});
// A flat series: episodes hang off the series, no season folders exist.
it("synthesizes headers when the server returned no seasons", () => {
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
expect(grouped.map((g) => g.season.name)).toEqual(["Season 1", "Season 2"]);
expect(grouped.every((g) => g.season.kind === "season")).toBe(true);
});
it("names a synthesized season 0 'Specials'", () => {
const grouped = groupEpisodesBySeason([], [ep("s0e1", 0, 1)]);
expect(grouped[0].season.name).toBe("Specials");
});
it("gives synthesized headers distinct ids so keyed #each blocks are stable", () => {
const grouped = groupEpisodesBySeason([], [ep("s1e1", 1, 1), ep("s2e1", 2, 1)]);
const ids = grouped.map((g) => g.season.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("drops seasons that have no episodes", () => {
const grouped = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[ep("s2e1", 2, 1)]
);
expect(grouped.map((g) => g.season.indexNumber)).toEqual([2]);
});
it("buckets season-less episodes into season 1 rather than losing them", () => {
const grouped = groupEpisodesBySeason([], [ep("lone", null, 1)]);
expect(grouped).toHaveLength(1);
expect(grouped[0].episodes.map((e) => e.id)).toEqual(["lone"]);
});
});
describe("initialExpandedSeasons", () => {
const seasons = groupEpisodesBySeason(
[seasonHeader(1), seasonHeader(2), seasonHeader(3)],
[
ep("s1e1", 1, 1),
ep("s2e1", 2, 1),
ep("s2e2", 2, 2),
ep("s3e1", 3, 1),
]
);
it("expands only the season holding the current episode", () => {
const expanded = initialExpandedSeasons(seasons, "s2e2");
expect([...expanded]).toEqual(["season-2"]);
});
it("also expands the season of a ?episode= deep link", () => {
const expanded = initialExpandedSeasons(seasons, "s1e1", "s3e1");
expect(expanded.has("season-1")).toBe(true);
expect(expanded.has("season-3")).toBe(true);
expect(expanded.has("season-2")).toBe(false);
});
it("collapses nothing extra when current and focused share a season", () => {
const expanded = initialExpandedSeasons(seasons, "s2e1", "s2e2");
expect([...expanded]).toEqual(["season-2"]);
});
it("falls back to the first season when there is no current episode", () => {
expect([...initialExpandedSeasons(seasons, null)]).toEqual(["season-1"]);
});
it("falls back to the first season when the current episode is unknown here", () => {
expect([...initialExpandedSeasons(seasons, "not-in-this-show")]).toEqual(["season-1"]);
});
it("returns nothing for a series with no seasons", () => {
expect(initialExpandedSeasons([], "s1e1").size).toBe(0);
});
});
@@ -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;
}
+49
View File
@@ -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);
}
});
});
+36
View File
@@ -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}`;
}
+3 -1
View File
@@ -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";
+119 -71
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-035, UR-038, UR-048 | DR-043, DR-062 -->
<!-- TRACES: UR-035, UR-038, UR-048, UR-062 | DR-043, DR-062, DR-102, DR-103 -->
<script lang="ts">
import { onMount, untrack } from "svelte";
import { page } from "$app/stores";
@@ -17,6 +17,7 @@
import SeasonSection from "$lib/components/library/SeasonSection.svelte";
import EpisodeFocusView from "$lib/components/library/EpisodeFocusView.svelte";
import SeriesDownloadButton from "$lib/components/library/SeriesDownloadButton.svelte";
import ClearHistoryButton from "$lib/components/library/ClearHistoryButton.svelte";
import VideoDownloadButton from "$lib/components/library/VideoDownloadButton.svelte";
import CastSection from "$lib/components/library/CastSection.svelte";
import PersonDetailView from "$lib/components/library/PersonDetailView.svelte";
@@ -28,17 +29,27 @@
import CachedImage from "$lib/components/common/CachedImage.svelte";
import BackButton from "$lib/components/common/BackButton.svelte";
import ArtistLinks from "$lib/components/library/ArtistLinks.svelte";
interface SeasonData {
season: MediaItem;
episodes: MediaItem[];
}
import {
groupEpisodesBySeason,
seasonAnchorId,
seasonRedirectTarget,
episodeFocusHref,
seriesPlayHref,
seriesPlayLabel,
initialExpandedSeasons,
type SeasonData,
} from "$lib/components/library/seriesNavigation";
let item = $state<MediaItem | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let seasonData = $state<SeasonData[]>([]);
let directFetchedEpisode = $state<MediaItem | null>(null);
// The episode the viewer is up to. Resolved by Rust (DR-101), not here.
let currentEpisode = $state<MediaItem | null>(null);
// Season ids whose episode list is open. A reading position, not a saved
// preference, so it resets with each load (DR-107).
let expandedSeasons = $state<Set<string>>(new Set());
// Track if we've done an initial load and previous server state
let hasLoadedOnce = false;
@@ -81,10 +92,25 @@
error = null;
seasonData = [];
directFetchedEpisode = null;
currentEpisode = null;
expandedSeasons = new Set();
}
try {
item = await library.loadItem(itemId);
// A season is not a destination — send it to its series, anchored at that
// season, so the episodes of every season stay one continuous list.
// TRACES: UR-062 | DR-103
if (item?.kind === "season") {
const target = seasonRedirectTarget(item);
if (target) {
await goto(target, { replaceState: true });
return;
}
// No seriesId (stale cache / deep link) — fall through to the generic
// rendering below rather than stranding the user.
}
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
if (item?.people) {
@@ -129,64 +155,42 @@
}
}
// For Series, load seasons and their episodes
// For Series, load every episode across all seasons plus the episode the
// viewer is up to. Both come from Rust: the season fan-out (and the
// flat-series fallback for shows whose children are episodes rather than
// season folders) is Jellyfin's shape, and "which episode is current" is
// domain policy — neither belongs in the presentation layer.
// TRACES: UR-062 | DR-101, DR-102
if (item?.kind === "series") {
const seasons = $libraryItems.filter((i) => i.kind === "season");
const repo = auth.getRepository();
const seasons = $libraryItems.filter((i) => i.kind === "season");
// Load episodes for each season in parallel
const seasonDataPromises = seasons.map(async (season) => {
const result = await repo.getItems(season.id, { limit: 100 });
const episodes = result.items
.filter((i) => i.kind === "episode")
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
return { season, episodes };
});
const [episodes, current] = await Promise.all([
repo.getSeriesEpisodes(itemId),
// Best-effort: a series still renders if the anchor cannot be resolved.
repo.getSeriesCurrentEpisode(itemId).catch((e) => {
console.warn("Could not resolve the current episode:", e);
return null;
}),
]);
seasonData = await Promise.all(seasonDataPromises);
// Sort seasons by index number
seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0));
// Some series expose episodes directly as children rather than under
// season folders. In that case the season fetch above yields nothing —
// group the flat episode children by their season number so the Episode
// Focus View still has a populated `allEpisodes` (otherwise "More
// Episodes" collapses to just the current episode).
if (seasonData.every((s) => s.episodes.length === 0)) {
const flatEpisodes = $libraryItems.filter((i) => i.kind === "episode");
if (flatEpisodes.length > 0) {
const bySeason = new Map<number, MediaItem[]>();
for (const ep of flatEpisodes) {
const key = ep.parentIndexNumber ?? 1;
(bySeason.get(key) ?? bySeason.set(key, []).get(key)!).push(ep);
}
seasonData = [...bySeason.entries()]
.sort(([a], [b]) => a - b)
.map(([seasonNumber, episodes]) => ({
// Synthesize a minimal season header from the episodes we have.
season: {
...(seasons.find((s) => s.indexNumber === seasonNumber) ?? episodes[0]),
kind: "season",
indexNumber: seasonNumber,
name: `Season ${seasonNumber}`,
} as MediaItem,
episodes: episodes.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)),
}));
}
}
seasonData = groupEpisodesBySeason(seasons, episodes);
currentEpisode = current;
// Open only the season the viewer is in (DR-107).
expandedSeasons = initialExpandedSeasons(
seasonData,
current?.id,
$page.url.searchParams.get("episode")
);
// If we have a focused episode ID but couldn't find it in the seasons,
// fetch it directly (handles ID mismatch between APIs)
const episodeIdParam = $page.url.searchParams.get("episode");
if (episodeIdParam) {
const allEps = seasonData.flatMap((s) => s.episodes);
const foundInSeasons = allEps.some((e) => e.id === episodeIdParam);
if (!foundInSeasons) {
try {
directFetchedEpisode = await repo.getItem(episodeIdParam);
} catch {
console.warn("Could not fetch focused episode directly:", episodeIdParam);
}
if (episodeIdParam && !episodes.some((e) => e.id === episodeIdParam)) {
try {
directFetchedEpisode = await repo.getItem(episodeIdParam);
} catch {
console.warn("Could not fetch focused episode directly:", episodeIdParam);
}
}
}
@@ -224,14 +228,21 @@
return;
}
switch (clickedItem.kind) {
case "series":
// A season link lands on its series, anchored at that season — seasons
// have no page of their own (DR-103).
case "season":
goto(seasonRedirectTarget(clickedItem) ?? `/library/${clickedItem.id}`);
break;
// An episode always opens in the context of its series (ux-flows §5B.1).
case "episode":
goto(episodeFocusHref(clickedItem));
break;
case "series":
case "album":
case "artist":
case "folder":
case "playlist":
case "channel":
case "episode":
case "movie":
goto(`/library/${clickedItem.id}`);
break;
@@ -244,15 +255,29 @@
// Removed custom handleTrackClick - let TrackList use its built-in playback logic
// This fixes Android playback issues where navigation-based approach was hanging
function toggleSeason(seasonId: string) {
// Reassign rather than mutate — a Set mutation is invisible to $state.
const next = new Set(expandedSeasons);
if (!next.delete(seasonId)) next.add(seasonId);
expandedSeasons = next;
}
function handleEpisodeClick(episode: MediaItem) {
// Play the episode with the series queued for next episode
goto(`/player/${episode.id}`);
// Swap focus to the episode in place; playback starts from the focus view's
// own Play button, never from a list tap (ux-flows §5B.1, §5B.5).
goto(episodeFocusHref(episode));
}
async function handlePlayAll() {
// For single items (Episode, Movie), play the item directly
if (item?.kind === "episode" || item?.kind === "movie") {
goto(`/player/${itemId}`);
} else if (item?.kind === "series" && itemId) {
// Open the episode the viewer is up to, where an explicit Play/Resume
// commits. Play on a container navigates; Play on a leaf plays.
// TRACES: UR-062 | DR-102
const target = seriesPlayHref(itemId, currentEpisode);
if (target) goto(target);
} else if (item?.kind === "album" && $libraryItems.length > 0) {
// For albums, use the backend command (backend fetches and queues all tracks)
try {
@@ -293,6 +318,11 @@
console.error("Failed to shuffle play album:", e);
alert(`Failed to shuffle play: ${e instanceof Error ? e.message : 'Unknown error'}`);
}
} else if (item?.kind === "series" && allEpisodes.length > 0) {
// Shuffle a *series* means a random episode, not a random season — the
// player has nothing to do with a season id.
const random = allEpisodes[Math.floor(Math.random() * allEpisodes.length)];
goto(`/player/${random.id}?restart=true`);
} else if ($libraryItems.length > 0) {
const randomIndex = Math.floor(Math.random() * $libraryItems.length);
goto(`/player/${$libraryItems[randomIndex].id}?queue=parent:${itemId}&shuffle=true`);
@@ -304,6 +334,10 @@
seasonData.flatMap((s) => s.episodes)
);
const playLabel = $derived(item?.kind === "series" ? seriesPlayLabel(currentEpisode) : "Play");
// An empty series has nowhere for the hero button to lead.
const canPlay = $derived(item?.kind !== "series" || currentEpisode !== null);
// Find the focused episode (check allEpisodes first, then fall back to directly fetched)
const focusedEpisode = $derived(
focusedEpisodeId
@@ -422,9 +456,11 @@
{/if}
{#if item.parentIndexNumber || item.indexNumber}
<p class="text-lg text-gray-400 mt-1">
{#if item.seasonId && item.parentIndexNumber}
<!-- Links to the season's place in the series list, not to a
season page — seasons have none (DR-103). -->
{#if item.seriesId && item.parentIndexNumber}
<a
href={`/library/${item.seasonId}`}
href={`/library/${item.seriesId}#${seasonAnchorId(item.parentIndexNumber)}`}
class="hover:underline hover:text-[var(--color-jellyfin)] transition-colors"
>Season {item.parentIndexNumber}</a>
{:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
@@ -466,15 +502,17 @@
<!-- Actions -->
<div class="flex gap-3 flex-wrap">
<button
onclick={handlePlayAll}
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play
</button>
{#if canPlay}
<button
onclick={handlePlayAll}
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] rounded-lg font-medium flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{playLabel}
</button>
{/if}
{#if item.kind !== "episode" && item.kind !== "movie"}
<button
onclick={handleShufflePlay}
@@ -498,6 +536,12 @@
seriesName={item.name}
episodeCount={allEpisodes.length || undefined}
/>
<ClearHistoryButton
itemId={item.id}
itemName={item.name}
scope="series"
onCleared={loadItem}
/>
{:else if item.kind === "movie"}
<VideoDownloadButton
itemId={item.id}
@@ -627,7 +671,11 @@
{season}
{episodes}
focusedEpisodeId={focusedEpisodeId ?? undefined}
currentEpisodeId={currentEpisode?.id}
expanded={expandedSeasons.has(season.id)}
onToggle={() => toggleSeason(season.id)}
onEpisodeClick={handleEpisodeClick}
onHistoryCleared={loadItem}
/>
{/each}
{/if}
+118 -107
View File
@@ -1,6 +1,15 @@
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
<!--
The Movies library — one page, three tabs.
Was three routes (`/library/movies`, `/library/movies/all`,
`/library/movies/genres`). 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-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";
@@ -9,32 +18,47 @@
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 { resolveLibraryView, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
import type { MediaItem } from "$lib/api/types";
interface Category {
id: string;
name: string;
icon: string;
description: string;
route: string;
}
const BASE_PATH = "/library/movies";
const categories: Category[] = [
{
id: "all",
name: "All Movies",
icon: "M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z",
description: "Browse all movies",
route: "/library/movies/all",
},
{
id: "genres",
name: "Genres",
icon: "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",
description: "Browse by genre",
route: "/library/movies/genres",
},
];
const view = $derived(resolveLibraryView($page.url.searchParams.get("view")));
const tabLabels: Record<LibraryView, string> = {
browse: "Browse",
all: "All Movies",
genres: "Genres",
};
const allMoviesConfig = {
itemType: "Movie" as const,
title: "Movies",
backPath: BASE_PATH,
searchPlaceholder: "Search movies...",
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: ["Movie" as const],
title: "Movie 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 movies found in this genre",
};
async function load() {
if (!$currentLibrary) {
@@ -70,92 +94,79 @@
const genreRows = $derived($movies.genreRows);
const isLoading = $derived($movies.isLoading);
const hasContent = $derived(
heroItems.length > 0 ||
continueWatching.length > 0 ||
recentlyAdded.length > 0
heroItems.length > 0 || continueWatching.length > 0 || recentlyAdded.length > 0
);
</script>
{#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 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 ?? "Movies"}</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>
{:else}
<div class="space-y-8 pb-8">
<!-- Header -->
<div class="flex items-center justify-between px-4">
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "Movies"}</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>
<LibraryViewTabs basePath={BASE_PATH} active={view} labels={tabLabels} />
{#if view === "all"}
<div class="px-4">
<GenericMediaListPage config={allMoviesConfig} showHeader={false} />
</div>
<!-- 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}
<!-- Recently Added -->
{#if recentlyAdded.length > 0}
<Carousel
title="Recently Added"
items={recentlyAdded}
onItemClick={handleItemClick}
showAll={() => goto("/library/movies/all")}
/>
{/if}
<!-- One slider per genre -->
{#each genreRows as row (row.id)}
<Carousel
title={row.name}
items={row.items}
onItemClick={handleItemClick}
showAll={() => goto(`/library/movies/genres`)}
/>
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Add some movies to your library to fill this page.</p>
{/if}
<!-- Browse by category -->
<div class="space-y-3 px-4 pt-4">
<h2 class="text-2xl font-semibold text-white">Browse</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each categories as category (category.id)}
<button
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d={category.icon} />
</svg>
</div>
<div class="min-w-0">
<div class="text-white font-semibold truncate">{category.name}</div>
<div class="text-gray-400 text-xs truncate">{category.description}</div>
</div>
</button>
{/each}
</div>
{:else if view === "genres"}
<div class="px-4">
<GenericGenreBrowser config={genresConfig} showHeader={false} />
</div>
</div>
{/if}
{: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}
<!-- 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. Add some movies to your library to fill this page.</p>
{/if}
</div>
{/if}
</div>
@@ -1,27 +0,0 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
/**
* Movie browser (all movies)
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "Movie" as const,
title: "Movies",
backPath: "/library/movies",
searchPlaceholder: "Search movies...",
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,
};
</script>
<GenericMediaListPage {config} />
+11
View File
@@ -0,0 +1,11 @@
// Legacy route — now the Movies library's All Movies tab.
//
// Kept as a redirect rather than deleted: GenreTags builds links to these
// paths and users have them in history.
//
// TRACES: UR-063 | DR-105
import { redirect } from "@sveltejs/kit";
export const load = () => {
redirect(307, "/library/movies?view=all");
};
@@ -1,23 +0,0 @@
<script lang="ts">
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
/**
* Movie genre browser
* @req: UR-007 - Navigate media in library
* @req: UR-030 - Quick genre browsing and filtering
* @req: DR-007 - Library browsing screens
*/
const config = {
itemTypes: ["Movie" as const],
title: "Movie Genres",
backPath: "/library",
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 movies found in this genre",
};
</script>
<GenericGenreBrowser {config} />
+11
View File
@@ -0,0 +1,11 @@
// Legacy route — now the Movies library's Genres tab.
//
// Kept as a redirect rather than deleted: GenreTags builds links to these
// paths and users have them in history.
//
// TRACES: UR-063 | DR-105
import { redirect } from "@sveltejs/kit";
export const load = () => {
redirect(307, "/library/movies?view=genres");
};
@@ -1,23 +0,0 @@
<script lang="ts">
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
/**
* TV show genre browser
* @req: UR-007 - Navigate media in library
* @req: UR-030 - Quick genre browsing and filtering
* @req: DR-007 - Library browsing screens
*/
const config = {
itemTypes: ["Series" as const],
title: "TV Genres",
backPath: "/library",
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",
};
</script>
<GenericGenreBrowser {config} />
+11
View File
@@ -0,0 +1,11 @@
// Legacy route — now the TV library's Genres tab.
//
// Kept as a redirect rather than deleted: GenreTags builds links to these
// paths and users have them in history.
//
// TRACES: UR-063 | DR-105
import { redirect } from "@sveltejs/kit";
export const load = () => {
redirect(307, "/library/tv?view=genres");
};
+132 -115
View File
@@ -1,6 +1,15 @@
<!-- TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039 -->
<!--
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";
@@ -9,32 +18,48 @@
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";
interface Category {
id: string;
name: string;
icon: string;
description: string;
route: string;
}
const BASE_PATH = "/library/tv";
const categories: Category[] = [
{
id: "shows",
name: "All Shows",
icon: "M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z",
description: "Browse all series",
route: "/library/tv/shows",
},
{
id: "genres",
name: "Genres",
icon: "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",
description: "Browse by genre",
route: "/library/shows/genres",
},
];
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) {
@@ -57,13 +82,20 @@
function handleItemClick(item: MediaItem) {
switch (item.type) {
case "Series":
// 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:
// Episodes and movies play directly.
goto(`/player/${item.id}`);
break;
}
@@ -83,95 +115,80 @@
);
</script>
{#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 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>
{:else}
<div class="space-y-8 pb-8">
<!-- Header -->
<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>
<LibraryViewTabs basePath={BASE_PATH} active={view} labels={tabLabels} />
{#if view === "all"}
<div class="px-4">
<GenericMediaListPage config={allShowsConfig} showHeader={false} />
</div>
<!-- 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("/library/tv/shows")}
/>
{/if}
<!-- One slider per genre -->
{#each genreRows as row (row.id)}
<Carousel
title={row.name}
items={row.items}
onItemClick={handleItemClick}
showAll={() => goto(`/library/shows/genres`)}
/>
{/each}
{#if !hasContent}
<p class="px-4 text-gray-400">Nothing here yet. Start watching something to fill this page.</p>
{/if}
<!-- Browse by category -->
<div class="space-y-3 px-4 pt-4">
<h2 class="text-2xl font-semibold text-white">Browse</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{#each categories as category (category.id)}
<button
onclick={() => goto(category.route)}
class="group relative flex items-center gap-3 bg-[var(--color-surface)] hover:bg-white/10 rounded-xl p-4 text-left transition-colors"
>
<div class="w-10 h-10 flex-shrink-0 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center group-hover:scale-110 transition-transform">
<svg class="w-5 h-5 text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
<path d={category.icon} />
</svg>
</div>
<div class="min-w-0">
<div class="text-white font-semibold truncate">{category.name}</div>
<div class="text-gray-400 text-xs truncate">{category.description}</div>
</div>
</button>
{/each}
</div>
{:else if view === "genres"}
<div class="px-4">
<GenericGenreBrowser config={genresConfig} showHeader={false} />
</div>
</div>
{/if}
{: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>
-27
View File
@@ -1,27 +0,0 @@
<script lang="ts">
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
/**
* TV show browser (all series)
* @req: UR-007 - Navigate media in library
* @req: UR-008 - Search media across libraries
* @req: DR-007 - Library browsing screens
*/
const config = {
itemType: "Series" as const,
title: "TV Shows",
backPath: "/library/tv",
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,
};
</script>
<GenericMediaListPage {config} />
+11
View File
@@ -0,0 +1,11 @@
// Legacy route — now the TV library's All Shows tab.
//
// Kept as a redirect rather than deleted: GenreTags builds links to these
// paths and users have them in history.
//
// TRACES: UR-063 | DR-105
import { redirect } from "@sveltejs/kit";
export const load = () => {
redirect(307, "/library/tv?view=all");
};