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:
2026-07-23 20:02:55 +02:00
parent 8f4f651bac
commit f25deba824
14 changed files with 1418 additions and 224 deletions
+15 -3
View File
@@ -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>
+58 -1
View File
@@ -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>