fix(purchases): persist download progress across navigation
Move download state from component-local $state() to a persistent module-level Svelte store (purchaseDownloadStore). Downloads continue in the background when navigating away from album detail, and progress bars are restored when returning. Closes #90
This commit is contained in:
1 parent
1ba4622828
commit
d8f28385d9
3 files changed
+150
-63
No files matched your search
@@ -6,9 +6,11 @@
|
||||
import QualityBadge from '../QualityBadge.svelte';
|
||||
import Dropdown from '../Dropdown.svelte';
|
||||
import ViewTransition from '../ViewTransition.svelte';
|
||||
import { getAlbumDetail, getFormats, downloadAlbum, downloadTrack, markTrackDownloaded } from '$lib/services/purchases';
|
||||
import { getAlbumDetail, getFormats } from '$lib/services/purchases';
|
||||
import { purchaseDownloads, startAlbumDownload, startTrackDownload } from '$lib/stores/purchaseDownloadStore';
|
||||
import type { TrackDownloadStatus } from '$lib/stores/purchaseDownloadStore';
|
||||
import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
|
||||
import type { PurchasedAlbum, PurchasedTrack, PurchaseFormatOption, PurchaseDownloadProgress } from '$lib/types/purchases';
|
||||
import type { PurchasedAlbum, PurchasedTrack, PurchaseFormatOption } from '$lib/types/purchases';
|
||||
import type { DisplayTrack } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -37,10 +39,11 @@
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Download state
|
||||
let downloadStatuses = $state<Map<number, PurchaseDownloadProgress['status']>>(new Map());
|
||||
let isDownloadingAll = $state(false);
|
||||
let allComplete = $state(false);
|
||||
// Download state (from persistent store — survives navigation)
|
||||
const albumDlState = $derived($purchaseDownloads[albumId] ?? null);
|
||||
const downloadStatuses = $derived(albumDlState?.trackStatuses ?? {});
|
||||
const isDownloadingAll = $derived(albumDlState?.isDownloadingAll ?? false);
|
||||
const allComplete = $derived(albumDlState?.allComplete ?? false);
|
||||
|
||||
function formatPurchaseDate(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
@@ -69,8 +72,8 @@
|
||||
if (fmt) selectedFormatId = fmt.id;
|
||||
}
|
||||
|
||||
function getTrackStatus(trackId: number): PurchaseDownloadProgress['status'] | null {
|
||||
return downloadStatuses.get(trackId) || null;
|
||||
function getTrackStatus(trackId: number): TrackDownloadStatus | null {
|
||||
return downloadStatuses[trackId] || null;
|
||||
}
|
||||
|
||||
function groupByDisc(trackList: PurchasedTrack[]): Map<number, PurchasedTrack[]> {
|
||||
@@ -109,6 +112,7 @@
|
||||
}
|
||||
|
||||
async function promptForFolder(action: 'all' | number) {
|
||||
if (!album || selectedFormatId === null) return;
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||
const { audioDir } = await import('@tauri-apps/api/path');
|
||||
@@ -120,50 +124,18 @@
|
||||
title: get(t)('purchases.chooseFolder'),
|
||||
});
|
||||
if (dest && typeof dest === 'string') {
|
||||
await executeDownload(action, dest);
|
||||
if (action === 'all') {
|
||||
const trackIds = (album.tracks?.items || []).map((track) => track.id);
|
||||
startAlbumDownload(albumId, trackIds, selectedFormatId, dest);
|
||||
} else {
|
||||
startTrackDownload(albumId, action, selectedFormatId, dest);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Folder picker error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeDownload(action: 'all' | number, destination: string) {
|
||||
if (!album || selectedFormatId === null) return;
|
||||
|
||||
if (action === 'all') {
|
||||
isDownloadingAll = true;
|
||||
const trackItems = album.tracks?.items || [];
|
||||
for (const track of trackItems) {
|
||||
downloadStatuses.set(track.id, 'downloading');
|
||||
downloadStatuses = new Map(downloadStatuses);
|
||||
try {
|
||||
const filePath = await downloadTrack(track.id, selectedFormatId, destination);
|
||||
downloadStatuses.set(track.id, 'complete');
|
||||
await markTrackDownloaded(track.id, album.id, filePath).catch(() => {});
|
||||
} catch {
|
||||
downloadStatuses.set(track.id, 'failed');
|
||||
}
|
||||
downloadStatuses = new Map(downloadStatuses);
|
||||
}
|
||||
isDownloadingAll = false;
|
||||
const allDone = trackItems.every(
|
||||
(track) => downloadStatuses.get(track.id) === 'complete'
|
||||
);
|
||||
if (allDone) allComplete = true;
|
||||
} else {
|
||||
downloadStatuses.set(action, 'downloading');
|
||||
downloadStatuses = new Map(downloadStatuses);
|
||||
try {
|
||||
const filePath = await downloadTrack(action, selectedFormatId, destination);
|
||||
downloadStatuses.set(action, 'complete');
|
||||
await markTrackDownloaded(action, album.id, filePath).catch(() => {});
|
||||
} catch {
|
||||
downloadStatuses.set(action, 'failed');
|
||||
}
|
||||
downloadStatuses = new Map(downloadStatuses);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlbum() {
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -189,7 +161,7 @@
|
||||
);
|
||||
const isMultiDisc = $derived(discGroups.size > 1);
|
||||
const completedCount = $derived(
|
||||
Array.from(downloadStatuses.values()).filter((s) => s === 'complete').length
|
||||
Object.values(downloadStatuses).filter((s) => s === 'complete').length
|
||||
);
|
||||
const totalTracks = $derived(album?.tracks?.items?.length || 0);
|
||||
const totalDurationSeconds = $derived(
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
} from 'lucide-svelte';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import QualityBadge from '../QualityBadge.svelte';
|
||||
import { getPurchasesByType, getPurchaseIds, searchPurchases, getDownloadedTrackIds, getFormats, downloadTrack, markTrackDownloaded } from '$lib/services/purchases';
|
||||
import { getPurchasesByType, getPurchaseIds, searchPurchases, getDownloadedTrackIds, getFormats } from '$lib/services/purchases';
|
||||
import { allTrackStatuses, startTrackDownload } from '$lib/stores/purchaseDownloadStore';
|
||||
import { showToast } from '$lib/stores/toastStore';
|
||||
import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
|
||||
import {
|
||||
@@ -378,10 +379,8 @@
|
||||
};
|
||||
}
|
||||
|
||||
let trackDownloadStatuses = $state<Map<number, 'downloading' | 'complete' | 'failed'>>(new Map());
|
||||
|
||||
function getTrackDownloadStatus(trackId: number): 'downloading' | 'complete' | 'failed' | null {
|
||||
return trackDownloadStatuses.get(trackId) || null;
|
||||
return $allTrackStatuses[trackId] || null;
|
||||
}
|
||||
|
||||
async function handleTrackDownload(event: MouseEvent, track: PurchasedTrack) {
|
||||
@@ -411,19 +410,8 @@
|
||||
|
||||
if (!dest || typeof dest !== 'string') return;
|
||||
|
||||
trackDownloadStatuses.set(track.id, 'downloading');
|
||||
trackDownloadStatuses = new Map(trackDownloadStatuses);
|
||||
|
||||
const filePath = await downloadTrack(track.id, formats[0].id, dest);
|
||||
await markTrackDownloaded(track.id, track.album.id, filePath).catch(() => {});
|
||||
|
||||
trackDownloadStatuses.set(track.id, 'complete');
|
||||
trackDownloadStatuses = new Map(trackDownloadStatuses);
|
||||
downloadedTrackIds.add(track.id);
|
||||
downloadedTrackIds = new Set(downloadedTrackIds);
|
||||
startTrackDownload(track.album.id, track.id, formats[0].id, dest);
|
||||
} catch (err) {
|
||||
trackDownloadStatuses.set(track.id, 'failed');
|
||||
trackDownloadStatuses = new Map(trackDownloadStatuses);
|
||||
console.error('Track download error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import { downloadTrack, markTrackDownloaded } from '$lib/services/purchases';
|
||||
|
||||
export type TrackDownloadStatus = 'downloading' | 'complete' | 'failed';
|
||||
|
||||
interface AlbumDownloadState {
|
||||
trackStatuses: Record<number, TrackDownloadStatus>;
|
||||
isDownloadingAll: boolean;
|
||||
allComplete: boolean;
|
||||
}
|
||||
|
||||
// Persistent store: albumId -> download state (survives component unmount)
|
||||
export const purchaseDownloads = writable<Record<string, AlbumDownloadState>>({});
|
||||
|
||||
// Flat view of all track statuses across all albums (for PurchasesView)
|
||||
export const allTrackStatuses = derived(purchaseDownloads, ($downloads) => {
|
||||
const flat: Record<number, TrackDownloadStatus> = {};
|
||||
for (const state of Object.values($downloads)) {
|
||||
for (const [trackId, status] of Object.entries(state.trackStatuses)) {
|
||||
flat[Number(trackId)] = status;
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
});
|
||||
|
||||
function updateAlbumState(
|
||||
albumId: string,
|
||||
updater: (state: AlbumDownloadState) => AlbumDownloadState
|
||||
) {
|
||||
purchaseDownloads.update((all) => {
|
||||
const current = all[albumId] ?? {
|
||||
trackStatuses: {},
|
||||
isDownloadingAll: false,
|
||||
allComplete: false,
|
||||
};
|
||||
return { ...all, [albumId]: updater(current) };
|
||||
});
|
||||
}
|
||||
|
||||
export function startAlbumDownload(
|
||||
albumId: string,
|
||||
trackIds: number[],
|
||||
formatId: number,
|
||||
destination: string
|
||||
) {
|
||||
updateAlbumState(albumId, () => ({
|
||||
trackStatuses: {},
|
||||
isDownloadingAll: true,
|
||||
allComplete: false,
|
||||
}));
|
||||
executeAlbumDownload(albumId, trackIds, formatId, destination);
|
||||
}
|
||||
|
||||
async function executeAlbumDownload(
|
||||
albumId: string,
|
||||
trackIds: number[],
|
||||
formatId: number,
|
||||
destination: string
|
||||
) {
|
||||
for (const trackId of trackIds) {
|
||||
updateAlbumState(albumId, (state) => ({
|
||||
...state,
|
||||
trackStatuses: { ...state.trackStatuses, [trackId]: 'downloading' },
|
||||
}));
|
||||
|
||||
try {
|
||||
const filePath = await downloadTrack(trackId, formatId, destination);
|
||||
updateAlbumState(albumId, (state) => ({
|
||||
...state,
|
||||
trackStatuses: { ...state.trackStatuses, [trackId]: 'complete' },
|
||||
}));
|
||||
await markTrackDownloaded(trackId, albumId, filePath).catch(() => {});
|
||||
} catch {
|
||||
updateAlbumState(albumId, (state) => ({
|
||||
...state,
|
||||
trackStatuses: { ...state.trackStatuses, [trackId]: 'failed' },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize
|
||||
const currentState = get(purchaseDownloads)[albumId];
|
||||
if (currentState) {
|
||||
const allDone = trackIds.every(
|
||||
(id) => currentState.trackStatuses[id] === 'complete'
|
||||
);
|
||||
updateAlbumState(albumId, (state) => ({
|
||||
...state,
|
||||
isDownloadingAll: false,
|
||||
allComplete: allDone,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function startTrackDownload(
|
||||
albumId: string,
|
||||
trackId: number,
|
||||
formatId: number,
|
||||
destination: string
|
||||
) {
|
||||
updateAlbumState(albumId, (state) => ({
|
||||
...state,
|
||||
trackStatuses: { ...state.trackStatuses, [trackId]: 'downloading' },
|
||||
}));
|
||||
executeSingleTrackDownload(albumId, trackId, formatId, destination);
|
||||
}
|
||||
|
||||
async function executeSingleTrackDownload(
|
||||
albumId: string,
|
||||
trackId: number,
|
||||
formatId: number,
|
||||
destination: string
|
||||
) {
|
||||
try {
|
||||
const filePath = await downloadTrack(trackId, formatId, destination);
|
||||
updateAlbumState(albumId, (state) => ({
|
||||
...state,
|
||||
trackStatuses: { ...state.trackStatuses, [trackId]: 'complete' },
|
||||
}));
|
||||
await markTrackDownloaded(trackId, albumId, filePath).catch(() => {});
|
||||
} catch {
|
||||
updateAlbumState(albumId, (state) => ({
|
||||
...state,
|
||||
trackStatuses: { ...state.trackStatuses, [trackId]: 'failed' },
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user