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
+16 -12
View File
@@ -10,8 +10,9 @@ import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { usePlaylistStore } from '../store/playlistStore';
import { open } from '@tauri-apps/plugin-shell';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/core';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { useTranslation } from 'react-i18next';
function sanitizeFilename(name: string): string {
@@ -279,19 +280,22 @@ export default function ContextMenu() {
};
const downloadAlbum = async (albumName: string, albumId: string) => {
try {
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
const url = buildDownloadUrl(albumId);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const buffer = await blob.arrayBuffer();
const path = await join(folder, `${sanitizeFilename(albumName)}.zip`);
await writeFile(path, new Uint8Array(buffer));
const filename = `${sanitizeFilename(albumName)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(albumId);
const id = crypto.randomUUID();
const { start, complete, fail } = useZipDownloadStore.getState();
start(id, filename);
try {
await invoke('download_zip', { id, url, destPath });
complete(id);
} catch (e) {
console.error('Download failed:', e);
fail(id);
console.error('ZIP download failed:', e);
}
};
+13 -2
View File
@@ -2,6 +2,7 @@ import React, { useState, useRef, useLayoutEffect, useEffect, useCallback } from
import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useAuthStore } from '../store/authStore';
import { useSidebarStore } from '../store/sidebarStore';
import { NavLink } from 'react-router-dom';
@@ -9,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3, Shuffle,
PanelLeftClose, PanelLeft, HelpCircle, Dices, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
ChevronDown, Check, Music2, TrendingUp, FolderOpen,
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X,
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -44,7 +45,8 @@ export default function Sidebar({
const { t } = useTranslation();
const isPlaying = usePlayerStore(s => s.isPlaying);
const currentTrack = usePlayerStore(s => s.currentTrack);
const offlineJobs = useOfflineStore(s => s.jobs);
const offlineJobs = useOfflineJobStore(s => s.jobs);
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const offlineAlbums = useOfflineStore(s => s.albums);
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -288,6 +290,15 @@ export default function Sidebar({
{!isCollapsed && (
<span>{t('sidebar.downloadingTracks', { n: activeJobs.length })}</span>
)}
<button
className="sidebar-offline-cancel"
onClick={cancelAllDownloads}
data-tooltip={t('sidebar.cancelDownload')}
data-tooltip-pos="right"
aria-label={t('sidebar.cancelDownload')}
>
<X size={12} />
</button>
</div>
)}
</nav>
+1 -2
View File
@@ -3,9 +3,8 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { X, Minus, Square } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
const win = getCurrentWindow();
export default function TitleBar() {
const win = getCurrentWindow();
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
+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,
);
}