feat(downloads): browsable downloaded library with on-disk usage
Replace the flat download list with a Downloaded browse surface that reuses the online grids/cards/detail pages, filtered to on-device media, plus a demoted Transfers tab. Add repository browse commands (getDownloadedLibraries/Items, disk usage) with offline/hybrid implementations, a downloadedCatalog service, formatBytes helper, and per-item/device disk-usage labels on cards and grids. Regenerated bindings. Also carries the inseparable UR-052 offline-filter hunks in offline.rs/hybrid.rs. TRACES: UR-055 | DR-081, DR-082, DR-083, DR-084; UR-056 | DR-085
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
<!--
|
||||
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 { 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;
|
||||
}
|
||||
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>
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- TRACES: UR-029, UR-051 | DR-069, DR-070 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import MediaCard from "./MediaCard.svelte";
|
||||
@@ -9,12 +10,20 @@
|
||||
title?: string;
|
||||
loading?: boolean;
|
||||
showViewToggle?: boolean;
|
||||
forceGrid?: boolean;
|
||||
musicContent?: boolean;
|
||||
onItemClick?: (item: MediaItem | Library) => void;
|
||||
/**
|
||||
* Optional per-item secondary label (e.g. on-disk size for the Downloaded
|
||||
* surface), forwarded to each card. TRACES: UR-056 | DR-085
|
||||
*/
|
||||
sizeLabelFor?: (item: MediaItem | Library) => string | undefined;
|
||||
/** Optional per-item container download badge for the Downloaded surface. */
|
||||
downloadedBadgeFor?: (item: MediaItem | Library) => "full" | "partial" | undefined;
|
||||
/** Optional per-item remove-from-device handler for the Downloaded surface. */
|
||||
onItemRemove?: (item: MediaItem | Library) => void;
|
||||
}
|
||||
|
||||
let { items, title, loading = false, showViewToggle = true, forceGrid = false, musicContent = false, onItemClick }: Props = $props();
|
||||
let { items, title, loading = false, showViewToggle = true, musicContent = false, onItemClick, sizeLabelFor, downloadedBadgeFor, onItemRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -65,7 +74,7 @@
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>No items found</p>
|
||||
</div>
|
||||
{:else if !forceGrid && $viewMode === "list"}
|
||||
{:else if $viewMode === "list"}
|
||||
<LibraryListView {items} showProgress={true} onItemClick={onItemClick} />
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
@@ -74,6 +83,9 @@
|
||||
<MediaCard
|
||||
{item}
|
||||
showProgress={true}
|
||||
sizeLabel={sizeLabelFor?.(item)}
|
||||
downloadedBadge={downloadedBadgeFor?.(item)}
|
||||
onRemove={onItemRemove ? () => onItemRemove(item) : undefined}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- TRACES: UR-051, UR-052 | DR-068, DR-078 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
@@ -12,10 +13,26 @@
|
||||
size?: "small" | "medium" | "large";
|
||||
showProgress?: boolean;
|
||||
showDownloadStatus?: boolean;
|
||||
/**
|
||||
* Secondary on-disk size label (e.g. "1.2 GB"), shown under the subtitle.
|
||||
* Used by the Downloaded browse surface. TRACES: UR-056 | DR-085
|
||||
*/
|
||||
sizeLabel?: string;
|
||||
/**
|
||||
* "full" | "partial" — badges a downloaded container on the artwork so a
|
||||
* fully-downloaded item reads differently from a partially-downloaded one.
|
||||
* TRACES: UR-055 | DR-083
|
||||
*/
|
||||
downloadedBadge?: "full" | "partial";
|
||||
/**
|
||||
* When set, a hover/focus "remove from device" control appears on the card
|
||||
* (Downloaded surface only). TRACES: UR-055, UR-056 | DR-083
|
||||
*/
|
||||
onRemove?: () => void;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, onclick }: Props = $props();
|
||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick }: Props = $props();
|
||||
|
||||
// Check if this item is downloaded
|
||||
const downloadInfo = $derived(
|
||||
@@ -219,6 +236,43 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Remove-from-device control (Downloaded surface), shown on hover/focus -->
|
||||
{#if onRemove}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); onRemove?.(); }}
|
||||
class="absolute top-2 left-2 w-7 h-7 rounded-full bg-black/70 hover:bg-red-600 text-white flex items-center justify-center opacity-0 group-hover/card:opacity-100 focus:opacity-100 transition-opacity shadow-lg"
|
||||
title="Remove from device"
|
||||
aria-label="Remove {item.name} from device"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12a1 1 0 001 1h6a1 1 0 001-1V7" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Container downloaded badge (Downloaded surface): full vs partial -->
|
||||
{#if downloadedBadge}
|
||||
<div
|
||||
class="absolute bottom-2 right-2"
|
||||
title={downloadedBadge === "full" ? "Fully downloaded" : "Partially downloaded"}
|
||||
>
|
||||
{#if downloadedBadge === "full"}
|
||||
<div class="w-6 h-6 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center shadow-lg" aria-label="Partially downloaded">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Server-only: queue-for-download control (kept at full opacity over the
|
||||
greyed artwork). Queued items show a "queued" badge instead. -->
|
||||
{#if isServerOnly}
|
||||
@@ -261,5 +315,8 @@
|
||||
{#if subtitle()}
|
||||
<p class="text-xs text-gray-400 truncate">{subtitle()}</p>
|
||||
{/if}
|
||||
{#if sizeLabel}
|
||||
<p class="text-xs text-gray-500 truncate">{sizeLabel}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
Reference in New Issue
Block a user