TRACES: | DR-204 484 ungated `console.*` calls across 63 non-test frontend files shipped to end users with no way to turn them off. Mechanical substitution, no control flow, error handling or message semantics changed: console.log / console.debug -> log.debug console.info -> log.info console.warn -> log.warn console.error -> log.error Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope now carries them; scope names that already existed are preserved verbatim (`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename where a file had none. `src/routes/player/[id]/+page.svelte` keeps its `NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than flattening them into the page scope. `grep -rn 'console\.' src/` now matches nothing outside the tests and the facade itself.
202 lines
7.5 KiB
Svelte
202 lines
7.5 KiB
Svelte
<!--
|
|
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 { 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 DownloadedBrowse from "$lib/components/downloads/DownloadedBrowse.svelte";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("DownloadsPage");
|
|
|
|
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() {
|
|
try {
|
|
loading = true;
|
|
const userId = $auth.user?.id;
|
|
if (userId) {
|
|
await downloads.refresh(userId);
|
|
}
|
|
} catch (error) {
|
|
log.error("Failed to load downloads:", error);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
if (download.status === "downloading") {
|
|
try {
|
|
await downloads.pause(download.id);
|
|
} catch (error) {
|
|
log.error(`Failed to pause download ${download.id}:`, error);
|
|
}
|
|
}
|
|
}
|
|
const userId = $auth.user?.id;
|
|
if (userId) await downloads.refresh(userId);
|
|
}
|
|
|
|
async function resumeAll() {
|
|
for (const download of transfers) {
|
|
if (download.status === "paused" || download.status === "failed") {
|
|
try {
|
|
await downloads.resume(download.id);
|
|
} catch (error) {
|
|
log.error(`Failed to resume download ${download.id}:`, error);
|
|
}
|
|
}
|
|
}
|
|
const userId = $auth.user?.id;
|
|
if (userId) await downloads.refresh(userId);
|
|
}
|
|
</script>
|
|
|
|
<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
|
|
onclick={() => goto("/library")}
|
|
class="p-2 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
|
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" />
|
|
</svg>
|
|
</button>
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-white">Downloads</h1>
|
|
<p class="text-gray-400">Your offline library and active transfers</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- View switch: Downloaded (default) / Transfers -->
|
|
<div class="flex gap-4 border-b border-gray-700">
|
|
<button
|
|
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'}"
|
|
>
|
|
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={() => (view = "transfers")}
|
|
class="pb-3 px-1 font-medium transition-colors relative {view === 'transfers'
|
|
? 'text-[var(--color-jellyfin)]'
|
|
: 'text-gray-400 hover:text-white'}"
|
|
>
|
|
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 view === "transfers"}
|
|
<div class="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-jellyfin)]"></div>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
{#if view === "downloaded"}
|
|
<DownloadedBrowse />
|
|
{:else}
|
|
<!-- 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}
|
|
class="px-4 py-2 bg-[var(--color-surface)] text-white rounded-lg font-medium hover:bg-[var(--color-surface-hover)] transition-colors text-sm"
|
|
>
|
|
Pause All
|
|
</button>
|
|
<button
|
|
onclick={resumeAll}
|
|
class="px-4 py-2 bg-[var(--color-surface)] text-white rounded-lg font-medium hover:bg-[var(--color-surface-hover)] transition-colors text-sm"
|
|
>
|
|
Resume All
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
{#each transfers as download (download.id)}
|
|
<DownloadItem {download} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|