fix(offline): cancellable downloads + stable sidebar progress toast (#694)

* fix(sidebar): keep offline-download toast from squishing in a short window

The toast lives in the sidebar nav flex column; without flex-shrink: 0 the
column compressed it vertically when the main window was small. The label
now also ellipsis-truncates instead of overflowing on a narrow sidebar.

* fix(offline): make offline downloads cancellable down to the Rust transfer

A running offline download could not be stopped — the sidebar X button only
dropped not-yet-started tracks between batches of 8, and the Rust transfer had
no cancellation path at all, so in-flight HTTP streams always ran to completion.

Add an offline_cancel_flags() registry (mirroring sync_cancel_flags for the
device-sync side) plus additive cancel_offline_downloads / clear_offline_cancel
commands. download_track_offline takes an optional download_id, checks the flag
right after acquiring its semaphore slot, and threads it through
finalize_streamed_download / stream_to_file so an in-flight stream aborts at the
next chunk — the partial .part file is cleaned up by the existing error path.

* fix(offline): cancel per-track and clear the sidebar toast immediately

downloadAlbum tags each run with a downloadId, checks for cancellation before
every track instead of once per 8-track batch (which never re-ran for albums of
8 or fewer tracks), and persists tracks that finished before the cancel so they
are not orphaned on disk. cancelDownload / cancelAllDownloads drop every job for
the album and call cancel_offline_downloads so Rust aborts the in-flight
transfers — the toast disappears at once instead of lingering on stuck rows.

Adds offlineJobStore cancellation tests.

* docs(changelog): offline download cancel button + toast sizing fixes
This commit is contained in:
Frank Stellmacher
2026-05-14 20:08:08 +02:00
committed by GitHub
parent 946528350c
commit b4c8ed4b65
12 changed files with 316 additions and 28 deletions
+20
View File
@@ -118,6 +118,9 @@ export const useOfflineStore = create<OfflineState>()(
const CONCURRENCY = 8;
const trackIds = songs.map(s => s.id);
const jobStore = useOfflineJobStore;
// Unique per run — keys the Rust-side cancellation flag so the X button
// can abort in-flight transfers, not just stop queuing new ones.
const downloadId = `${albumId}-${Date.now()}`;
// Pre-flight: verify the target directory is accessible before queuing anything.
const customDir = useAuthStore.getState().offlineDownloadDir || null;
@@ -149,6 +152,7 @@ export const useOfflineStore = create<OfflineState>()(
trackIndex: i,
totalTracks: songs.length,
status: 'queued' as const,
downloadId,
})),
],
}));
@@ -160,7 +164,13 @@ export const useOfflineStore = create<OfflineState>()(
// Abort if the user cancelled this download.
if (cancelledDownloads.has(albumId)) {
cancelledDownloads.delete(albumId);
// Persist whatever finished before the cancel so files already on
// disk are not orphaned, then drop the remaining jobs.
if (Object.keys(completedTracks).length > 0) {
set(state => ({ tracks: { ...state.tracks, ...completedTracks } }));
}
jobStore.setState(state => ({ jobs: state.jobs.filter(j => j.albumId !== albumId) }));
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
return;
}
@@ -180,6 +190,11 @@ export const useOfflineStore = create<OfflineState>()(
const results = await Promise.all(
batch.map(async song => {
const suffix = song.suffix || 'mp3';
// Skip tracks not yet started once the user cancels mid-batch —
// the per-batch check above only catches whole batches.
if (cancelledDownloads.has(albumId)) {
return { song, suffix, localPath: null as string | null, error: 'CANCELLED' };
}
try {
const localPath = await invoke<string>('download_track_offline', {
trackId: song.id,
@@ -187,6 +202,7 @@ export const useOfflineStore = create<OfflineState>()(
url: buildStreamUrl(song.id),
suffix,
customDir,
downloadId,
});
return { song, suffix, localPath, error: null as string | null };
} catch (err) {
@@ -233,6 +249,9 @@ export const useOfflineStore = create<OfflineState>()(
if (j.albumId !== albumId) return j;
const r = resultMap.get(j.trackId);
if (!r) return j;
// A cancelled track is not a failure — leave the job for the
// cancel path to drop rather than flashing it red.
if (r.error === 'CANCELLED') return j;
return { ...j, status: r.localPath ? 'done' : 'error' };
}),
}));
@@ -240,6 +259,7 @@ export const useOfflineStore = create<OfflineState>()(
// Persist all completed tracks in ONE localStorage write.
set(state => ({ tracks: { ...state.tracks, ...completedTracks } }));
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
// Clear completed jobs after a short delay.
setTimeout(() => {