Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
232 lines
8.2 KiB
Svelte
232 lines
8.2 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>
|