feat: fix UI freezes in ZIP and offline cache downloads

ZIP downloads (PlaylistDetail, Albums, NewReleases, RandomAlbums) now use
invoke('download_zip') via Rust streaming instead of fetch+blob+arrayBuffer,
eliminating JS heap saturation on large files. Progress shown in ZipDownloadOverlay.

Offline cache downloads (600+ songs) no longer freeze the UI: transient job
state moved to a separate non-persisted offlineJobStore, reducing localStorage
writes from ~1200 down to 2 for an entire download. Also adds playlist offline
toggle — clicking the cache button when already cached removes it from cache.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-08 22:33:23 +02:00
parent c1e57b4c06
commit ba670bd1e8
25 changed files with 771 additions and 252 deletions
+13 -10
View File
@@ -8,9 +8,10 @@ import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
const ALBUM_COUNT = 30;
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
@@ -43,7 +44,7 @@ export default function RandomAlbums() {
const mixMinRatingAlbum = auth.mixMinRatingAlbum;
const mixMinRatingArtist = auth.mixMinRatingArtist;
const serverId = auth.activeServerId ?? '';
const { downloadAlbum } = useOfflineStore();
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
@@ -65,21 +66,23 @@ export default function RandomAlbums() {
if (selectedAlbums.length === 0) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
let done = 0;
const { start, complete, fail } = useZipDownloadStore.getState();
clearSelection();
for (const album of selectedAlbums) {
showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info');
const downloadId = crypto.randomUUID();
const filename = `${sanitizeFilename(album.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(album.id);
start(downloadId, filename);
try {
const blob = await fetch(buildDownloadUrl(album.id)).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
const path = await join(folder, `${sanitizeFilename(album.name)}.zip`);
await writeFile(path, new Uint8Array(await blob.arrayBuffer()));
done++;
await invoke('download_zip', { id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
console.error('ZIP download failed for', album.name, e);
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
}
}
showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info');
clearSelection();
};
const handleAddOffline = async () => {