🏗️ 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
201 lines
7.9 KiB
Svelte
201 lines
7.9 KiB
Svelte
<!--
|
|
Downloaded browse surface: the library, filtered to what's on the device.
|
|
|
|
Reuses the library's own grid/cards. The top level lists only libraries with
|
|
downloaded content; drilling into a library shows its downloaded items in the
|
|
same grid used online. Clicking a leaf/detail item navigates to the shared
|
|
`/library/[id]` detail page, where Play uses the local file. Per-item and
|
|
device disk usage ride along via the size labels and the top bar.
|
|
|
|
TRACES: UR-055, UR-056 | DR-081, DR-082, DR-083, DR-085
|
|
-->
|
|
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
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,
|
|
downloadedLibraries,
|
|
downloadedDeviceTotal,
|
|
downloadedItemCount,
|
|
} from "$lib/services/downloadedCatalog";
|
|
|
|
// Drill state: null = library list; otherwise the library we're inside.
|
|
let currentLibrary = $state<Library | null>(null);
|
|
let items = $state<MediaItem[]>([]);
|
|
let loadingItems = $state(false);
|
|
let loadError = $state<string | null>(null);
|
|
|
|
const loading = $derived($downloadedCatalog.loading);
|
|
|
|
onMount(() => {
|
|
void downloadedCatalog.refresh();
|
|
});
|
|
|
|
async function openLibrary(library: Library) {
|
|
currentLibrary = library;
|
|
loadingItems = true;
|
|
loadError = null;
|
|
try {
|
|
items = await downloadedCatalog.loadItems(library.id);
|
|
} catch (err) {
|
|
loadError = err instanceof Error ? err.message : "Failed to load downloads";
|
|
items = [];
|
|
} finally {
|
|
loadingItems = false;
|
|
}
|
|
}
|
|
|
|
function backToLibraries() {
|
|
currentLibrary = null;
|
|
items = [];
|
|
loadError = null;
|
|
}
|
|
|
|
// Containers (album/season/series/box set) drill via the shared detail page,
|
|
// which is offline-aware; leaves open their detail/play surface there too.
|
|
function onItemClick(item: MediaItem | Library) {
|
|
if ("collectionType" in item) {
|
|
// A Library (top level) — drill in place.
|
|
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}`);
|
|
}
|
|
|
|
// A size label for a card, if we have a byte figure for it.
|
|
function sizeLabelFor(item: MediaItem | Library): string | undefined {
|
|
const bytes = $downloadedCatalog.sizes[item.id];
|
|
return bytes && bytes > 0 ? formatBytes(bytes) : undefined;
|
|
}
|
|
|
|
// Remove a downloaded item/container, stating the reclaim amount first.
|
|
async function removeItem(item: MediaItem | Library) {
|
|
if (!("type" in item)) return;
|
|
const bytes = $downloadedCatalog.sizes[item.id] ?? 0;
|
|
const freed = bytes > 0 ? ` This frees ${formatBytes(bytes)}.` : "";
|
|
if (!confirm(`Remove “${item.name}” from this device?${freed}`)) return;
|
|
try {
|
|
await downloadedCatalog.remove(item.id);
|
|
// Reload the current library so removed items (and now-empty containers)
|
|
// drop out of the browse.
|
|
if (currentLibrary) {
|
|
items = await downloadedCatalog.loadItems(currentLibrary.id);
|
|
}
|
|
} catch (err) {
|
|
loadError = err instanceof Error ? err.message : "Failed to remove download";
|
|
}
|
|
}
|
|
|
|
// Full vs partial container badge (leaves get no container badge here).
|
|
function downloadedBadgeFor(item: MediaItem | Library): "full" | "partial" | undefined {
|
|
if (!("type" in item)) return undefined;
|
|
const isContainer = ["MusicAlbum", "Series", "Season", "BoxSet"].includes(item.type);
|
|
if (!isContainer) return undefined;
|
|
return $downloadedCatalog.partialContainers[item.id] ? "partial" : "full";
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-5">
|
|
<!-- Device total: the headline figure, reconciles with the listed sum. -->
|
|
<div
|
|
class="flex items-center justify-between rounded-lg border border-gray-700 bg-[var(--color-surface)] px-4 py-3"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<svg class="h-5 w-5 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.8">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10a2 2 0 002 2h12a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H6a2 2 0 00-2 2z" />
|
|
</svg>
|
|
<p class="text-sm text-gray-200">
|
|
<span class="font-semibold text-white">{formatBytes($downloadedDeviceTotal)}</span>
|
|
on device
|
|
<span class="text-gray-500">·</span>
|
|
{$downloadedItemCount}
|
|
{$downloadedItemCount === 1 ? "item" : "items"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{#if currentLibrary}
|
|
<!-- Inside a library: breadcrumb back to the library list. -->
|
|
<div class="flex items-center gap-2 text-sm">
|
|
<button
|
|
onclick={backToLibraries}
|
|
class="text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
Downloaded
|
|
</button>
|
|
<span class="text-gray-600">/</span>
|
|
<span class="text-white font-medium">{currentLibrary.name}</span>
|
|
</div>
|
|
|
|
{#if loadError}
|
|
<p class="text-sm text-red-400">{loadError}</p>
|
|
{/if}
|
|
|
|
<LibraryGrid
|
|
items={items.map((i) => i)}
|
|
loading={loadingItems}
|
|
showViewToggle={true}
|
|
musicContent={currentLibrary.collectionType === "music"}
|
|
{sizeLabelFor}
|
|
{downloadedBadgeFor}
|
|
onItemRemove={removeItem}
|
|
{onItemClick}
|
|
/>
|
|
{#if !loadingItems && items.length === 0 && !loadError}
|
|
<p class="text-center py-8 text-gray-500 text-sm">Nothing downloaded in this library.</p>
|
|
{/if}
|
|
{:else if loading}
|
|
<p class="text-center py-12 text-gray-400">Loading your downloads…</p>
|
|
{:else if $downloadedLibraries.length === 0}
|
|
<!-- Empty Downloaded state: authoritative "nothing downloaded", not a server miss. -->
|
|
<div class="rounded-lg border border-gray-700 bg-[var(--color-surface)] p-10 text-center">
|
|
<svg class="mx-auto mb-4 h-14 w-14 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
|
</svg>
|
|
<p class="text-lg font-medium text-gray-300">Nothing downloaded yet</p>
|
|
<p class="mt-2 text-sm text-gray-500">
|
|
Browse your library and tap download to save media for offline.
|
|
</p>
|
|
<button
|
|
onclick={() => goto("/library")}
|
|
class="mt-5 rounded-lg bg-[var(--color-jellyfin)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 transition"
|
|
>
|
|
Go to library
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<!-- Library list — only libraries with downloaded content. -->
|
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
|
{#each $downloadedLibraries as lib (lib.id)}
|
|
<button
|
|
onclick={() => openLibrary(lib)}
|
|
class="group/card flex flex-col text-left transition-transform duration-200 hover:scale-105"
|
|
>
|
|
<div class="relative aspect-video w-full overflow-hidden rounded-lg bg-[var(--color-surface)] shadow-md flex items-center justify-center">
|
|
<svg class="h-10 w-10 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.4">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-7l-2-2H5a2 2 0 00-2 2z" />
|
|
</svg>
|
|
</div>
|
|
<p class="mt-2 truncate text-sm font-medium text-white group-hover/card:text-[var(--color-jellyfin)] transition-colors">
|
|
{lib.name}
|
|
</p>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|