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
+78
View File
@@ -0,0 +1,78 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { HardDriveDownload, Check, X } from 'lucide-react';
import { useZipDownloadStore } from '../store/zipDownloadStore';
function formatMB(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function ZipDownloadItem({ id }: { id: string }) {
const dismiss = useZipDownloadStore(s => s.dismiss);
const item = useZipDownloadStore(s => s.downloads.find(d => d.id === id));
// Auto-dismiss 3 s after completion or error.
useEffect(() => {
if (!item?.done && !item?.error) return;
const timer = setTimeout(() => dismiss(id), 3000);
return () => clearTimeout(timer);
}, [item?.done, item?.error, id, dismiss]);
if (!item) return null;
const pct = item.total && item.total > 0
? Math.min(100, (item.bytes / item.total) * 100)
: null;
const isIndeterminate = !item.done && !item.error && (item.total === null || item.total === 0);
return (
<div className={`zip-dl-item${item.done ? ' zip-dl-done' : item.error ? ' zip-dl-error' : ''}`}>
<div className="zip-dl-header">
{item.done
? <Check size={13} />
: item.error
? <X size={13} />
: <HardDriveDownload size={13} className="spin-slow" />
}
<span className="zip-dl-name" data-tooltip={item.filename} data-tooltip-pos="top">{item.filename}</span>
{(item.done || item.error) && (
<button className="zip-dl-close" onClick={() => dismiss(id)} aria-label="Close">
<X size={10} />
</button>
)}
</div>
{!item.done && !item.error && (
<>
<div className="zip-dl-info">
{formatMB(item.bytes)}
{item.total !== null && item.total > 0 && (
<> / {formatMB(item.total)} &nbsp;({pct!.toFixed(0)}%)</>
)}
</div>
<div className={`zip-dl-track${isIndeterminate ? ' zip-dl-indeterminate' : ''}`}>
{!isIndeterminate && pct !== null && (
<div className="zip-dl-fill" style={{ width: `${pct}%` }} />
)}
</div>
</>
)}
</div>
);
}
export default function ZipDownloadOverlay() {
// Subscribe to the array reference directly — never derive a new array in the selector
// (selector returning new array on every call causes an infinite re-render loop).
const downloads = useZipDownloadStore(s => s.downloads);
if (downloads.length === 0) return null;
return createPortal(
<div className="zip-dl-overlay">
{downloads.map(d => <ZipDownloadItem key={d.id} id={d.id} />)}
</div>,
document.body,
);
}