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:
@@ -1,18 +1,37 @@
|
||||
<!--
|
||||
Downloads surface (UR-055): two views under /downloads.
|
||||
|
||||
- Downloaded (default): the library filtered to what's on the device, using the
|
||||
same browse grids/cards/detail pages as online. Backed by the offline-only
|
||||
repository path (never merges server results).
|
||||
- Transfers: the in-flight transfer rows (downloading / queued / paused /
|
||||
failed / waiting-for-WiFi) with per-row controls. Completed transfers fall
|
||||
off this view — they appear in Downloaded.
|
||||
|
||||
Initiating downloads stays on item/album/series detail pages (§7.1); this page
|
||||
manages and browses only.
|
||||
|
||||
TRACES: UR-055, UR-056 | DR-081, DR-082, DR-083, DR-084, DR-085
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { downloads, activeDownloads, completedDownloads, pendingDownloads, failedDownloads } from "$lib/stores/downloads";
|
||||
import { downloads, activeDownloads, pendingDownloads, failedDownloads, waitingForNetwork } from "$lib/stores/downloads";
|
||||
import { areDownloadsAllowed } from "$lib/services/networkType";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
|
||||
import StorageManagement from "$lib/components/downloads/StorageManagement.svelte";
|
||||
import DownloadedBrowse from "$lib/components/downloads/DownloadedBrowse.svelte";
|
||||
|
||||
type TabType = "active" | "completed";
|
||||
let activeTab = $state<TabType>("active");
|
||||
type ViewType = "downloaded" | "transfers";
|
||||
let view = $state<ViewType>("downloaded");
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
await loadDownloads();
|
||||
// Seed the WiFi-gate state on entry: the 'waitingForNetwork' event only
|
||||
// fires when the pump runs, so a queue parked before this page opened would
|
||||
// otherwise show no explanation.
|
||||
waitingForNetwork.set(!(await areDownloadsAllowed()));
|
||||
});
|
||||
|
||||
async function loadDownloads() {
|
||||
@@ -29,8 +48,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
const activeDownloadsList = $derived($activeDownloads.concat($pendingDownloads));
|
||||
const completedDownloadsList = $derived($completedDownloads.concat($failedDownloads));
|
||||
// Transfers = everything still in flight or waiting. Completed rows are
|
||||
// deliberately excluded — they live in Downloaded, not here.
|
||||
const transfers = $derived(
|
||||
$activeDownloads.concat($pendingDownloads).concat($failedDownloads)
|
||||
);
|
||||
|
||||
async function pauseAll() {
|
||||
for (const download of $activeDownloads) {
|
||||
@@ -42,15 +64,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refresh to update UI with new states
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
if (userId) await downloads.refresh(userId);
|
||||
}
|
||||
|
||||
async function resumeAll() {
|
||||
for (const download of activeDownloadsList) {
|
||||
for (const download of transfers) {
|
||||
if (download.status === "paused" || download.status === "failed") {
|
||||
try {
|
||||
await downloads.resume(download.id);
|
||||
@@ -59,42 +78,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refresh to update UI with new states
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCompleted() {
|
||||
for (const download of $completedDownloads) {
|
||||
try {
|
||||
await downloads.delete(download.id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete download ${download.id}:`, error);
|
||||
}
|
||||
}
|
||||
// Refresh to update UI
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearStale() {
|
||||
try {
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await commands.clearStaleDownloads(userId);
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to clear stale downloads:", error);
|
||||
}
|
||||
if (userId) await downloads.refresh(userId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-6 p-6">
|
||||
<div class="max-w-5xl mx-auto space-y-6 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
@@ -103,93 +92,87 @@
|
||||
title="Back to library"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Downloads</h1>
|
||||
<p class="text-gray-400">Manage your offline media downloads</p>
|
||||
<h1 class="text-3xl font-bold text-white">Downloads</h1>
|
||||
<p class="text-gray-400">Your offline library and active transfers</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={loadDownloads}
|
||||
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
title="Refresh downloads"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Storage Management -->
|
||||
<StorageManagement />
|
||||
|
||||
<!-- Tabs -->
|
||||
<!-- View switch: Downloaded (default) / Transfers -->
|
||||
<div class="flex gap-4 border-b border-gray-700">
|
||||
<button
|
||||
onclick={() => (activeTab = "active")}
|
||||
class="pb-3 px-1 font-medium transition-colors relative {activeTab === 'active'
|
||||
onclick={() => (view = "downloaded")}
|
||||
class="pb-3 px-1 font-medium transition-colors relative {view === 'downloaded'
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-400 hover:text-white'}"
|
||||
>
|
||||
Active
|
||||
{#if activeDownloadsList.length > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">
|
||||
{activeDownloadsList.length}
|
||||
</span>
|
||||
{/if}
|
||||
{#if activeTab === "active"}
|
||||
Downloaded
|
||||
{#if view === "downloaded"}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-jellyfin)]"></div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = "completed")}
|
||||
class="pb-3 px-1 font-medium transition-colors relative {activeTab === 'completed'
|
||||
onclick={() => (view = "transfers")}
|
||||
class="pb-3 px-1 font-medium transition-colors relative {view === 'transfers'
|
||||
? 'text-[var(--color-jellyfin)]'
|
||||
: 'text-gray-400 hover:text-white'}"
|
||||
>
|
||||
Completed
|
||||
{#if completedDownloadsList.length > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-green-500/20 text-green-400">
|
||||
{completedDownloadsList.length}
|
||||
Transfers
|
||||
<!-- Draw attention only while transfers are active. -->
|
||||
{#if transfers.length > 0}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-blue-500/20 text-blue-400">
|
||||
{transfers.length}
|
||||
</span>
|
||||
{/if}
|
||||
{#if activeTab === "completed"}
|
||||
{#if view === "transfers"}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-jellyfin)]"></div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Color coding legend -->
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-3 border border-gray-700">
|
||||
<div class="flex items-center gap-6 text-xs text-gray-400">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded bg-green-500/50"></div>
|
||||
<span><span class="text-green-400 font-medium">Green</span> = Downloads you chose</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-1 h-4 rounded bg-blue-500/50"></div>
|
||||
<span><span class="text-blue-400 font-medium">Blue</span> = Auto-cached content</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-center py-12 text-gray-400">
|
||||
<p>Loading downloads...</p>
|
||||
</div>
|
||||
{#if view === "downloaded"}
|
||||
<DownloadedBrowse />
|
||||
{:else}
|
||||
<!-- Bulk Actions -->
|
||||
{#if activeTab === "active" && activeDownloadsList.length > 0}
|
||||
<!-- WiFi-only gate notice (UR-053): explains an otherwise stuck-looking queue -->
|
||||
{#if $waitingForNetwork && transfers.length > 0}
|
||||
<div class="flex items-start gap-3 rounded-lg border border-amber-700 bg-amber-900/20 p-4" role="status">
|
||||
<svg class="mt-0.5 h-5 w-5 shrink-0 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18h.01M8.111 15.111a5.5 5.5 0 017.778 0M4.929 11.929a10 10 0 0114.142 0M12 21h.01" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-medium text-amber-200">Waiting for WiFi</p>
|
||||
<p class="mt-1 text-sm text-amber-200/80">
|
||||
Downloads are paused because “WiFi Only” is enabled and this device is
|
||||
on a metered or cellular connection. They'll resume automatically on
|
||||
an unmetered network.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="text-center py-12 text-gray-400"><p>Loading transfers…</p></div>
|
||||
{:else if transfers.length === 0}
|
||||
<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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<p class="text-lg font-medium text-gray-300">Nothing downloading</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}
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={pauseAll}
|
||||
@@ -203,121 +186,12 @@
|
||||
>
|
||||
Resume All
|
||||
</button>
|
||||
<button
|
||||
onclick={clearStale}
|
||||
class="px-4 py-2 bg-yellow-500/20 text-yellow-400 rounded-lg font-medium hover:bg-yellow-500/30 transition-colors text-sm"
|
||||
title="Remove all pending, paused, and failed downloads"
|
||||
>
|
||||
Clear Stale
|
||||
</button>
|
||||
</div>
|
||||
{:else if activeTab === "completed" && completedDownloadsList.length > 0}
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={clearCompleted}
|
||||
class="px-4 py-2 bg-red-500/20 text-red-400 rounded-lg font-medium hover:bg-red-500/30 transition-colors text-sm"
|
||||
>
|
||||
Clear Completed
|
||||
</button>
|
||||
<button
|
||||
onclick={async () => {
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
if (confirm('Delete ALL downloads (including completed)? This cannot be undone.')) {
|
||||
await commands.deleteAllDownloads(userId);
|
||||
await downloads.refresh(userId);
|
||||
}
|
||||
}
|
||||
}}
|
||||
class="px-4 py-2 bg-red-600/30 text-red-300 rounded-lg font-medium hover:bg-red-600/40 transition-colors text-sm border border-red-500/50"
|
||||
title="Delete all downloads and files"
|
||||
>
|
||||
Delete All Content
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Downloads List -->
|
||||
<div class="space-y-3">
|
||||
{#if activeTab === "active"}
|
||||
{#if activeDownloadsList.length === 0}
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-12 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto text-gray-600 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-gray-400 text-lg font-medium">No active downloads</p>
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
Downloads you start will appear here
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each activeDownloadsList as download (download.id)}
|
||||
<DownloadItem {download} />
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
{#if completedDownloadsList.length === 0}
|
||||
<div class="bg-[var(--color-surface)] rounded-lg p-12 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto text-gray-600 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-gray-400 text-lg font-medium">No completed downloads</p>
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
Finished downloads will appear here
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each completedDownloadsList as download (download.id)}
|
||||
<DownloadItem {download} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
{#if activeDownloadsList.length === 0 && completedDownloadsList.length === 0}
|
||||
<div class="bg-blue-900/20 border border-blue-800 rounded-lg p-4">
|
||||
<div class="flex gap-3">
|
||||
<svg
|
||||
class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-sm text-blue-300">
|
||||
<p class="font-semibold mb-1">Getting started with downloads:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-blue-200">
|
||||
<li>Look for the download icon next to tracks, albums, and playlists</li>
|
||||
<li>Downloaded media is available for offline playback</li>
|
||||
<li>Configure download settings in the Settings page</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each transfers as download (download.id)}
|
||||
<DownloadItem {download} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user