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.
209 lines
6.6 KiB
Svelte
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>
|