feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
Opening a series dumped the viewer at the top of season 1, and its Play button played nothing at all: it resolved `$libraryItems[0]` — the first *season* by SortName — and navigated to `/player/<seasonId>`, which the player route bounced straight back to `/library/<seasonId>`. The backend could already answer "where is this viewer in this show": `repository_get_next_up_episodes` has accepted a `series_id` since it was written and no caller had ever passed one. Backend (DR-101, DR-106) - `repository/series_progress.rs`: `pick_current_episode` — in progress, else Next Up, else first unwatched, else the premiere. The third rung is the offline path, where Next Up is always empty. `sort_series_order` puts specials (season 0) after the numbered seasons. - `repository_get_series_episodes` takes over the season fan-out and the flat-series fallback, which were domain knowledge living in the frontend. - `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a container, also zeroes resume). Offline it refuses rather than diverging state the next sync would undo. Frontend (DR-102, DR-103, DR-104, DR-107) - Seasons collapse; only the current one is expanded, and the current episode is badged and scrolled into view. - Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's focus view, where Play commits (ux-flows §5B.5). - Seasons are no longer a destination: `/library/<seasonId>` redirects to `/library/<seriesId>#season-N`, and every inbound link follows. - The "More Episodes" strip spans the whole series, so a season finale offers the next premiere instead of dead-ending (§5B.2). - Clear-history buttons on the series hero and each season header. Routes (DR-105) - `/library/tv` and `/library/movies` absorb their all-titles and genres pages as `?view=` tabs; the four legacy routes redirect. 6 video routes become 2, and `/library/shows/genres` stops being the odd one out. Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and `libraryView.ts` so it is unit-tested rather than buried in components. Spec: docs/specs/series-current-episode-navigation.md
This commit is contained in:
@@ -1,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}
|
||||
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
@@ -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");
|
||||
};
|
||||
Reference in New Issue
Block a user