Files
jellytau/src/lib/components/library/AlbumDownloadButton.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
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.
2026-08-21 17:41:44 +02:00

209 lines
6.6 KiB
Svelte

<script lang="ts">
import { downloads } from "$lib/stores/downloads";
import { auth } from "$lib/stores/auth";
import type { MediaItem } from "$lib/api/types";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("AlbumDownloadButton");
interface Props {
albumId: string;
albumName: string;
tracks: MediaItem[];
className?: string;
}
let { albumId, albumName, tracks, className = "" }: Props = $props();
let isProcessing = $state(false);
// Calculate download status for all tracks in album
const downloadStatuses = $derived(
tracks.map((track) => Object.values($downloads.downloads).find((d) => d.itemId === track.id)),
);
const completedCount = $derived(downloadStatuses.filter((d) => d?.status === "completed").length);
const downloadingCount = $derived(
downloadStatuses.filter((d) => d?.status === "downloading" || d?.status === "pending").length,
);
const failedCount = $derived(downloadStatuses.filter((d) => d?.status === "failed").length);
const totalProgress = $derived(() => {
if (tracks.length === 0) return 0;
const activeDownloads = downloadStatuses.filter((d) => d?.status === "downloading");
if (activeDownloads.length === 0) return completedCount / tracks.length;
const downloadingProgress = activeDownloads.reduce((sum, d) => sum + (d?.progress || 0), 0);
return (completedCount + downloadingProgress) / tracks.length;
});
const isFullyDownloaded = $derived(completedCount === tracks.length && tracks.length > 0);
const isDownloading = $derived(downloadingCount > 0);
const hasPartialDownload = $derived(completedCount > 0 && completedCount < tracks.length);
async function handleClick() {
if (isProcessing) return;
isProcessing = true;
try {
const userId = $auth.user?.id;
if (!userId) {
log.error("No user ID found");
return;
}
if (isFullyDownloaded) {
// Delete all downloads for this album
for (const status of downloadStatuses) {
if (status?.id) {
await downloads.delete(status.id);
}
}
} else if (isDownloading) {
// Cancel all active downloads for this album
for (const status of downloadStatuses) {
if (status?.id && (status.status === "downloading" || status.status === "pending")) {
await downloads.cancel(status.id);
}
}
} else {
// Download the album. One call: the backend lists the album's tracks
// from the server, queues every one of them, resolves each stream URL
// and pumps the queue. This page's `tracks` are what the user is
// looking at, not the download list — pairing them against the returned
// ids by position is what used to leave most of an album unqueued.
const repo = auth.getRepository();
const basePath = `albums/${albumId}`;
await downloads.downloadAlbum(repo.getHandle(), albumId, userId, basePath);
// Refresh to get updated statuses
await downloads.refresh(userId);
}
} catch (error) {
log.error("Album download operation failed:", error);
} finally {
isProcessing = false;
}
}
function getTitle(): string {
if (isFullyDownloaded) {
return "Downloaded - Click to remove";
}
if (isDownloading) {
return `Downloading ${completedCount + downloadingCount}/${tracks.length}... Click to cancel`;
}
if (hasPartialDownload) {
return `${completedCount}/${tracks.length} downloaded - Click to download remaining`;
}
if (failedCount > 0) {
return `${failedCount} failed - Click to retry`;
}
return "Download for offline playback";
}
function getStatusText(): string {
if (isFullyDownloaded) return "";
if (isDownloading || hasPartialDownload) {
return `${completedCount}/${tracks.length}`;
}
return "";
}
</script>
<button
onclick={handleClick}
disabled={isProcessing || tracks.length === 0}
class="px-6 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors {isFullyDownloaded
? 'bg-green-600 hover:bg-green-700 text-white'
: isDownloading
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)]'} {isProcessing
? 'opacity-50 cursor-wait'
: ''} {className}"
title={getTitle()}
aria-label={getTitle()}
>
<div class="relative w-5 h-5">
{#if isDownloading}
<!-- Progress ring -->
<svg class="absolute inset-0 -rotate-90" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
stroke-width="2"
opacity="0.3"
/>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-dasharray={2 * Math.PI * 10}
stroke-dashoffset={2 * Math.PI * 10 * (1 - totalProgress())}
stroke-linecap="round"
class="transition-all duration-300"
/>
</svg>
<!-- Small download icon inside -->
<svg
class="absolute inset-0 m-auto w-3 h-3"
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-4m-9 7h10"
/>
</svg>
{:else if isFullyDownloaded}
<!-- Checkmark icon -->
<svg class="w-5 h-5" 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>
{:else if failedCount > 0}
<!-- Error icon -->
<svg
class="w-5 h-5 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
{:else}
<!-- Download icon -->
<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="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10"
/>
</svg>
{/if}
</div>
{#if isDownloading || hasPartialDownload}
<span>{getStatusText()}</span>
{:else if isFullyDownloaded}
<span>Downloaded</span>
{:else}
<span>Download</span>
{/if}
</button>