jellytau/src/lib/components/library/AlbumDownloadButton.svelte
Duncan Tourolle e8e37649fa
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 18s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 2s
Many improvemtns and fixes related to decoupling of svelte and rust on android.
2026-02-28 19:50:47 +01:00

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>