250 lines
7.1 KiB
Svelte
250 lines
7.1 KiB
Svelte
<script lang="ts">
|
|
import { downloads } from "$lib/stores/downloads";
|
|
import { auth } from "$lib/stores/auth";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
|
|
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) {
|
|
console.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: queue all tracks, then start each one
|
|
const repo = auth.getRepository();
|
|
const basePath = `albums/${albumId}`;
|
|
const downloadIds = await downloads.downloadAlbum(albumId, userId, basePath);
|
|
|
|
// Get target directory for downloads
|
|
const targetDir = await invoke<string>("storage_get_path");
|
|
|
|
// Start each queued track download
|
|
for (let i = 0; i < tracks.length && i < downloadIds.length; i++) {
|
|
try {
|
|
const streamUrl = await repo.getAudioStreamUrl(tracks[i].id);
|
|
if (streamUrl) {
|
|
await invoke("start_download", {
|
|
downloadId: downloadIds[i],
|
|
streamUrl,
|
|
targetDir,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error(`Failed to start download for track ${tracks[i].id}:`, e);
|
|
}
|
|
}
|
|
|
|
// Refresh to get updated statuses
|
|
await downloads.refresh(userId);
|
|
}
|
|
} catch (error) {
|
|
console.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>
|