mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)} ({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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user