Twelve requirements were marked Done in docs/requirements.md with zero TRACES anywhere in the tree. The features work — the tags were simply never written — so the matrix over-reported on exactly the requirements a reviewer would most want to verify. Each is now tagged at the code that actually implements it: - JA-006 / JA-009 / JA-013 / JA-014 / JA-015 / JA-018 and IR-022 / IR-024 at their Jellyfin call sites in repository/online.rs (search, get_item's MediaStreams/People fields, Items/Resume, Shows/NextUp, FavoriteItems DELETE, get_person/get_items_by_person), plus the commands that expose them. - UR-006 / IR-006 across the lockscreen spine: JellyTauPlaybackService (the MediaSessionCompat owner), the nativeOnMediaCommand JNI intake, and LockscreenMetadata / update_lockscreen_metadata. - IR-008 at both audio-focus mechanisms — ExoPlayer-managed for audio, the manual AudioFocusRequest listener for video — and at the media-type string that chooses between them. - UR-037 (with DR-042, also untraced) on the video-library poster grid: LibraryGrid, MediaCard, and the tv/movies routes. Resolve contradictory statuses across layers, evidence first: - IR-018/IR-019 were Planned under Done URs because they were scoped to libmpv. MpvBackend is the audio-only backend and overrides neither set_subtitle_track nor set_audio_track — the trait's not_implemented() default still stands — so UR-020/UR-021 are met by ExoPlayer and by the HTML5 <video> path instead. Both IRs are re-scoped to those backends and marked Done; IT-008/IT-009 and the stale @req-planned markers in backend.rs follow. - IR-005 (MPRIS) stays Planned: there is no MPRIS/D-Bus code or dependency in the project and update_lockscreen_metadata is a no-op off Android. UR-006 is corrected to Done (Android) rather than the IR being marked Done. - A note under the IR table records where a UR is met by a different mechanism than its IR anticipated. Define the two dangling IDs the source already referenced: DR-189 (the control bar never auto-hid on a touchscreen, because its timer was armed only from onmousemove) and UT-188 (its rule test). The live-denominator assertion in extract-traces.test.ts moves 187/330 to 188/331 accordingly. Traced requirements 444 to 459; IR coverage 19/32 to 25/32.
198 lines
6.6 KiB
Svelte
198 lines
6.6 KiB
Svelte
<!--
|
|
The TV library — one page, three tabs.
|
|
|
|
Was three routes (`/library/tv`, `/library/tv/shows`, `/library/shows/genres`,
|
|
the last of which did not even share a prefix with the others). They are now
|
|
`?view=browse|all|genres` here; the old routes redirect.
|
|
|
|
TRACES: UR-007, UR-023, UR-034, UR-037, UR-063 | DR-007, DR-038, DR-039, DR-042, DR-103, DR-105
|
|
-->
|
|
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { page } from "$app/stores";
|
|
import { goto } from "$app/navigation";
|
|
import { navigateUp } from "$lib/utils/navigation";
|
|
import { library, currentLibrary } from "$lib/stores/library";
|
|
import { tv } from "$lib/stores/tv";
|
|
import { isServerReachable } from "$lib/stores/connectivity";
|
|
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
|
|
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
|
|
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
|
|
import Carousel from "$lib/components/home/Carousel.svelte";
|
|
import LibraryViewTabs from "$lib/components/library/LibraryViewTabs.svelte";
|
|
import GenericMediaListPage from "$lib/components/library/GenericMediaListPage.svelte";
|
|
import GenericGenreBrowser from "$lib/components/library/GenericGenreBrowser.svelte";
|
|
import { seasonRedirectTarget, episodeFocusHref } from "$lib/components/library/seriesNavigation";
|
|
import { resolveLibraryView, libraryViewUrl, type LibraryView } from "$lib/utils/libraryView";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
|
|
const BASE_PATH = "/library/tv";
|
|
|
|
const view = $derived(resolveLibraryView($page.url.searchParams.get("view")));
|
|
|
|
const tabLabels: Record<LibraryView, string> = {
|
|
browse: "Browse",
|
|
all: "All Shows",
|
|
genres: "Genres",
|
|
};
|
|
|
|
const allShowsConfig = {
|
|
itemType: "Series" as const,
|
|
title: "TV Shows",
|
|
backPath: BASE_PATH,
|
|
searchPlaceholder: "Search shows...",
|
|
sortOptions: [
|
|
{ key: "SortName", label: "A-Z" },
|
|
{ key: "ProductionYear", label: "Year" },
|
|
{ key: "DateCreated", label: "Recently Added" },
|
|
{ key: "CommunityRating", label: "Rating" },
|
|
],
|
|
defaultSort: "SortName",
|
|
displayComponent: "grid" as const,
|
|
};
|
|
|
|
const genresConfig = {
|
|
itemTypes: ["Series" as const],
|
|
title: "TV Genres",
|
|
backPath: BASE_PATH,
|
|
genreIcon:
|
|
"M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z",
|
|
itemDisplayMode: "poster" as const,
|
|
searchPlaceholder: "Search genres...",
|
|
noItemsMessage: "No shows found in this genre",
|
|
};
|
|
|
|
async function load() {
|
|
if (!$currentLibrary) {
|
|
goto("/library");
|
|
return;
|
|
}
|
|
await tv.loadSections($currentLibrary.id);
|
|
}
|
|
|
|
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
|
|
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
|
|
useOfflineFilterReload(load);
|
|
|
|
onMount(async () => {
|
|
await load();
|
|
markLoaded();
|
|
});
|
|
|
|
$effect(() => {
|
|
checkServerReachability($isServerReachable);
|
|
});
|
|
|
|
function handleItemClick(item: MediaItem) {
|
|
switch (item.type) {
|
|
// A season lands on its series, anchored at that season (DR-103).
|
|
case "Season":
|
|
goto(seasonRedirectTarget(item) ?? `/library/${item.id}`);
|
|
break;
|
|
// An episode opens inside its series, never as a bare episode page and
|
|
// never straight into the player (ux-flows §5B.1, §5B.5).
|
|
case "Episode":
|
|
goto(episodeFocusHref(item));
|
|
break;
|
|
case "Series":
|
|
case "Folder":
|
|
goto(`/library/${item.id}`);
|
|
break;
|
|
default:
|
|
goto(`/player/${item.id}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const heroItems = $derived($tv.heroItems);
|
|
const continueWatching = $derived($tv.continueWatching);
|
|
const nextUp = $derived($tv.nextUp);
|
|
const recentlyAdded = $derived($tv.recentlyAdded);
|
|
const genreRows = $derived($tv.genreRows);
|
|
const isLoading = $derived($tv.isLoading);
|
|
const hasContent = $derived(
|
|
heroItems.length > 0 ||
|
|
continueWatching.length > 0 ||
|
|
nextUp.length > 0 ||
|
|
recentlyAdded.length > 0
|
|
);
|
|
</script>
|
|
|
|
<div class="space-y-6 pb-8">
|
|
<!-- Header — one per page, shared by every tab -->
|
|
<div class="flex items-center justify-between px-4">
|
|
<h1 class="text-3xl font-bold text-white">{$currentLibrary?.name ?? "TV Shows"}</h1>
|
|
<button
|
|
onclick={() => { library.setCurrentLibrary(null); navigateUp("/library"); }}
|
|
class="p-2 rounded-lg hover:bg-white/10 transition-colors text-gray-400 hover:text-white"
|
|
title="Back to libraries"
|
|
aria-label="Back to libraries"
|
|
>
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<LibraryViewTabs basePath={BASE_PATH} active={view} labels={tabLabels} />
|
|
|
|
{#if view === "all"}
|
|
<div class="px-4">
|
|
<GenericMediaListPage config={allShowsConfig} showHeader={false} />
|
|
</div>
|
|
{:else if view === "genres"}
|
|
<div class="px-4">
|
|
<GenericGenreBrowser config={genresConfig} showHeader={false} />
|
|
</div>
|
|
{:else if isLoading}
|
|
<div class="flex justify-center items-center py-32">
|
|
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-8">
|
|
<!-- Hero Banner -->
|
|
{#if heroItems.length > 0}
|
|
<HeroBanner items={heroItems} />
|
|
{/if}
|
|
|
|
<!-- Continue Watching -->
|
|
{#if continueWatching.length > 0}
|
|
<Carousel
|
|
title="Continue Watching"
|
|
items={continueWatching}
|
|
onItemClick={handleItemClick}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Next Up -->
|
|
{#if nextUp.length > 0}
|
|
<Carousel title="Next Up" items={nextUp} onItemClick={handleItemClick} />
|
|
{/if}
|
|
|
|
<!-- Recently Added -->
|
|
{#if recentlyAdded.length > 0}
|
|
<Carousel
|
|
title="Recently Added"
|
|
items={recentlyAdded}
|
|
onItemClick={handleItemClick}
|
|
showAll={() => goto(libraryViewUrl(BASE_PATH, "all"))}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- One slider per genre -->
|
|
{#each genreRows as row (row.id)}
|
|
<Carousel
|
|
title={row.name}
|
|
items={row.items}
|
|
onItemClick={handleItemClick}
|
|
showAll={() => goto(libraryViewUrl(BASE_PATH, "genres"))}
|
|
/>
|
|
{/each}
|
|
|
|
{#if !hasContent}
|
|
<p class="px-4 text-gray-400">Nothing here yet. Start watching something to fill this page.</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|