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
+1 -1
View File
@@ -35,7 +35,7 @@ symphonia = { version = "0.5", default-features = false, features = ["flac", "mp
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "multipart", "rustls-tls", "blocking"] }
futures-util = "0.3"
md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
tokio = { version = "1", features = ["rt", "time", "sync"] }
biquad = "0.4"
ringbuf = "0.3"
tauri-plugin-window-state = "2.4.1"
+151 -7
View File
@@ -5,7 +5,7 @@ mod audio;
mod discord;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use std::sync::atomic::Ordering;
use tauri::{
@@ -18,6 +18,13 @@ use tauri::{
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
type ShortcutMap = Mutex<HashMap<String, String>>;
/// Maximum number of offline track downloads that can run concurrently.
/// The frontend queues more tasks than this; Rust is the real throttle.
const MAX_DL_CONCURRENCY: usize = 4;
/// Shared semaphore that caps simultaneous `download_track_offline` executions.
type DownloadSemaphore = Arc<tokio::sync::Semaphore>;
/// Holds the live system-tray icon handle. `None` means the tray is currently hidden/removed.
/// Dropping the inner `TrayIcon` fully removes it from the OS notification area on all platforms.
type TrayState = Mutex<Option<TrayIcon>>;
@@ -399,8 +406,32 @@ fn mpris_set_playback(
.map_err(|e| format!("MPRIS set_playback failed: {e:?}"))
}
/// Returns true if `path` is an accessible directory (used for pre-flight checks in the frontend).
#[tauri::command]
fn check_dir_accessible(path: String) -> bool {
std::path::Path::new(&path).is_dir()
}
// ─── Offline Track Cache ──────────────────────────────────────────────────────
/// Streams an HTTP response body directly to `dest_path` in small chunks.
/// Never buffers the full file in memory — keeps RAM flat regardless of file size.
async fn stream_to_file(response: reqwest::Response, dest_path: &std::path::Path) -> Result<(), String> {
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::File::create(dest_path)
.await
.map_err(|e| e.to_string())?;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| e.to_string())?;
file.write_all(&chunk).await.map_err(|e| e.to_string())?;
}
file.flush().await.map_err(|e| e.to_string())?;
Ok(())
}
/// Downloads a single track to the app's offline cache directory.
/// Returns the absolute file path so TypeScript can store it and later
/// construct a `psysonic-local://<path>` URL for the audio engine.
@@ -411,6 +442,7 @@ async fn download_track_offline(
url: String,
suffix: String,
custom_dir: Option<String>,
dl_sem: tauri::State<'_, DownloadSemaphore>,
app: tauri::AppHandle,
) -> Result<String, String> {
// Determine base cache directory.
@@ -436,11 +468,15 @@ async fn download_track_offline(
let file_path = cache_dir.join(format!("{}.{}", track_id, suffix));
let path_str = file_path.to_string_lossy().to_string();
// Already cached — skip re-download.
// Already cached — skip re-download (no semaphore needed).
if file_path.exists() {
return Ok(path_str);
}
// Acquire a download slot. The permit is held for the duration of the HTTP transfer
// and released automatically when this function returns (success or error).
let _permit = dl_sem.acquire().await.map_err(|e| e.to_string())?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
@@ -450,8 +486,14 @@ async fn download_track_offline(
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
tokio::fs::write(&file_path, &bytes)
// Stream directly to a .part file; rename on success to avoid partial files.
let part_path = file_path.with_extension(format!("{suffix}.part"));
if let Err(e) = stream_to_file(response, &part_path).await {
let _ = tokio::fs::remove_file(&part_path).await;
return Err(e);
}
tokio::fs::rename(&part_path, &file_path)
.await
.map_err(|e| e.to_string())?;
@@ -569,6 +611,96 @@ fn resolve_hot_cache_root(
}
}
/// Progress payload emitted to the frontend during a ZIP download.
/// `total` is `None` when the server doesn't send a `Content-Length` header
/// (Navidrome on-the-fly ZIPs).
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ZipProgress {
id: String,
bytes: u64,
total: Option<u64>,
}
/// Downloads a server-generated ZIP (album/playlist) directly to disk via streaming.
/// Emits `download:zip:progress` events every 500 ms so the frontend can show
/// live MB-counter without holding any binary data in the WebView process.
/// Returns the final destination path on success.
#[tauri::command]
async fn download_zip(
id: String,
url: String,
dest_path: String,
app: tauri::AppHandle,
) -> Result<String, String> {
use futures_util::StreamExt;
use std::time::{Duration, Instant};
use tokio::io::AsyncWriteExt;
const EMIT_INTERVAL: Duration = Duration::from_millis(500);
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(7200)) // up to 2 h for large on-the-fly ZIPs
.build()
.map_err(|e| e.to_string())?;
let response = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let total = response.content_length(); // None for Navidrome on-the-fly ZIPs
let part_path = format!("{dest_path}.part");
// Stream to .part file; rename on success, delete on error.
let result: Result<u64, String> = async {
let mut file = tokio::fs::File::create(&part_path)
.await
.map_err(|e| e.to_string())?;
let mut bytes_done: u64 = 0;
let mut stream = response.bytes_stream();
let mut last_emit = Instant::now();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| e.to_string())?;
file.write_all(&chunk).await.map_err(|e| e.to_string())?;
bytes_done += chunk.len() as u64;
if last_emit.elapsed() >= EMIT_INTERVAL {
let _ = app.emit("download:zip:progress", ZipProgress {
id: id.clone(),
bytes: bytes_done,
total,
});
last_emit = Instant::now();
}
}
file.flush().await.map_err(|e| e.to_string())?;
Ok(bytes_done)
}.await;
match result {
Err(e) => {
let _ = tokio::fs::remove_file(&part_path).await;
Err(e)
}
Ok(bytes_done) => {
// Final emission so the frontend sees 100 % (or final MB count).
let _ = app.emit("download:zip:progress", ZipProgress {
id: id.clone(),
bytes: bytes_done,
total: Some(bytes_done),
});
tokio::fs::rename(&part_path, &dest_path)
.await
.map_err(|e| e.to_string())?;
Ok(dest_path)
}
}
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct HotCacheDownloadResult {
@@ -618,12 +750,21 @@ async fn download_track_hot_cache(
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status().as_u16()));
}
let bytes = response.bytes().await.map_err(|e| e.to_string())?;
tokio::fs::write(&file_path, &bytes)
// Stream directly to a .part file; rename on success to avoid partial files.
let part_path = file_path.with_extension(format!("{suffix}.part"));
if let Err(e) = stream_to_file(response, &part_path).await {
let _ = tokio::fs::remove_file(&part_path).await;
return Err(e);
}
tokio::fs::rename(&part_path, &file_path)
.await
.map_err(|e| e.to_string())?;
let size = bytes.len() as u64;
let size = tokio::fs::metadata(&file_path)
.await
.map(|m| m.len())
.unwrap_or(0);
Ok(HotCacheDownloadResult {
path: path_str,
size,
@@ -870,6 +1011,7 @@ pub fn run() {
.manage(audio_engine)
.manage(ShortcutMap::default())
.manage(discord::DiscordState::new())
.manage(Arc::new(tokio::sync::Semaphore::new(MAX_DL_CONCURRENCY)) as DownloadSemaphore)
.manage(TrayState::default())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
@@ -1062,6 +1204,8 @@ pub fn run() {
delete_hot_cache_track,
purge_hot_cache,
toggle_tray_icon,
check_dir_accessible,
download_zip,
])
.run(tauri::generate_context!())
.expect("error while running Psysonic");
+12
View File
@@ -67,6 +67,8 @@ import { useFontStore } from './store/fontStore';
import { useEqStore } from './store/eqStore';
import { useKeybindingsStore } from './store/keybindingsStore';
import { useGlobalShortcutsStore } from './store/globalShortcutsStore';
import { useZipDownloadStore } from './store/zipDownloadStore';
import ZipDownloadOverlay from './components/ZipDownloadOverlay';
function RequireAuth({ children }: { children: React.ReactNode }) {
const { isLoggedIn, servers, activeServerId } = useAuthStore();
@@ -397,6 +399,15 @@ function TauriEventBridge() {
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
// ZIP download progress events from Rust
useEffect(() => {
let unlisten: (() => void) | undefined;
listen<{ id: string; bytes: number; total: number | null }>('download:zip:progress', e => {
useZipDownloadStore.getState().updateProgress(e.payload.id, e.payload.bytes, e.payload.total);
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
// Sync tray-icon visibility with the user's stored setting.
// Runs once on mount (initial sync) and again whenever the setting changes.
const showTrayIcon = useAuthStore(s => s.showTrayIcon);
@@ -609,6 +620,7 @@ export default function App() {
/>
</Routes>
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
<ZipDownloadOverlay />
</BrowserRouter>
);
}
+13 -9
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 filename = `${sanitizeFilename(albumName)}.zip`;
const destPath = await join(folder, filename);
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 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,
);
}
+2
View File
@@ -16,6 +16,7 @@ export const deTranslation = {
expand: 'Sidebar einblenden',
collapse: 'Sidebar ausblenden',
downloadingTracks: '{{n}} Tracks werden gecacht…',
cancelDownload: 'Download abbrechen',
offlineLibrary: 'Offline-Bibliothek',
genres: 'Genres',
playlists: 'Playlists',
@@ -823,6 +824,7 @@ export const deTranslation = {
addSong: 'Zur Playlist hinzufügen',
cacheOffline: 'Playlist offline speichern',
offlineCached: 'Playlist gecacht',
removeOffline: 'Aus Offline-Cache entfernen',
offlineDownloading: 'Wird gecacht… ({{done}}/{{total}} Alben)',
publicLabel: 'Öffentlich',
privateLabel: 'Privat',
+2
View File
@@ -17,6 +17,7 @@ export const enTranslation = {
collapse: 'Collapse Sidebar',
downloadingTracks: 'Caching {{n}} tracks…',
cancelDownload: 'Cancel download',
offlineLibrary: 'Offline Library',
genres: 'Genres',
playlists: 'Playlists',
@@ -824,6 +825,7 @@ export const enTranslation = {
addSong: 'Add to playlist',
cacheOffline: 'Cache playlist offline',
offlineCached: 'Playlist cached',
removeOffline: 'Remove from offline cache',
offlineDownloading: 'Caching… ({{done}}/{{total}} albums)',
publicLabel: 'Public',
privateLabel: 'Private',
+2
View File
@@ -16,6 +16,7 @@ export const frTranslation = {
expand: 'Développer la barre latérale',
collapse: 'Réduire la barre latérale',
downloadingTracks: '{{n}} pistes en cache…',
cancelDownload: 'Annuler le téléchargement',
offlineLibrary: 'Bibliothèque hors ligne',
genres: 'Genres',
playlists: 'Playlists',
@@ -821,6 +822,7 @@ export const frTranslation = {
addSong: 'Ajouter à la playlist',
cacheOffline: 'Mettre la playlist hors ligne',
offlineCached: 'Playlist en cache',
removeOffline: 'Retirer du cache hors ligne',
offlineDownloading: 'En cache… ({{done}}/{{total}} albums)',
publicLabel: 'Publique',
privateLabel: 'Privée',
+2
View File
@@ -16,6 +16,7 @@ export const nbTranslation = {
expand: 'Utvid sidefelt',
collapse: 'Skjul sidefelt',
downloadingTracks: 'Bufre {{n}} spor…',
cancelDownload: 'Avbryt nedlasting',
offlineLibrary: 'Frakoblet bibliotek',
genres: 'Sjangere',
playlists: 'Spillelister',
@@ -820,6 +821,7 @@ export const nbTranslation = {
addSong: 'Legg til i spilleliste',
cacheOffline: 'Bufre spilleliste offline',
offlineCached: 'Spilleliste bufret',
removeOffline: 'Fjern fra offline-buffer',
offlineDownloading: 'Bufre… ({{done}}/{{total}} album)',
publicLabel: 'Offentlig',
privateLabel: 'Privat',
+2
View File
@@ -16,6 +16,7 @@ export const nlTranslation = {
expand: 'Zijbalk uitklappen',
collapse: 'Zijbalk inklappen',
downloadingTracks: '{{n}} nummers worden gecached…',
cancelDownload: 'Download annuleren',
offlineLibrary: 'Offline bibliotheek',
genres: 'Genres',
playlists: 'Playlists',
@@ -821,6 +822,7 @@ export const nlTranslation = {
addSong: 'Toevoegen aan playlist',
cacheOffline: 'Playlist offline opslaan',
offlineCached: 'Playlist gecached',
removeOffline: 'Verwijder uit offline cache',
offlineDownloading: 'Cachen… ({{done}}/{{total}} albums)',
publicLabel: 'Openbaar',
privateLabel: 'Privé',
+2
View File
@@ -17,6 +17,7 @@ export const ruTranslation = {
expand: 'Развернуть боковую панель',
collapse: 'Свернуть боковую панель',
downloadingTracks: 'Кэширование {{n}} треков…',
cancelDownload: 'Отменить загрузку',
offlineLibrary: 'Офлайн-библиотека',
genres: 'Жанры',
playlists: 'Плейлисты',
@@ -878,6 +879,7 @@ export const ruTranslation = {
addSong: 'В плейлист',
cacheOffline: 'Сохранить плейлист офлайн',
offlineCached: 'Плейлист сохранён',
removeOffline: 'Удалить из офлайн-кэша',
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
publicLabel: 'Публичный',
privateLabel: 'Личный',
+2
View File
@@ -16,6 +16,7 @@ export const zhTranslation = {
expand: '展开侧边栏',
collapse: '收起侧边栏',
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
cancelDownload: '取消下载',
offlineLibrary: '离线音乐库',
genres: '流派',
playlists: '播放列表',
@@ -817,6 +818,7 @@ export const zhTranslation = {
addSong: '添加到播放列表',
cacheOffline: '离线缓存播放列表',
offlineCached: '播放列表已缓存',
removeOffline: '从离线缓存中移除',
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
publicLabel: '公开',
privateLabel: '私有',
+48 -60
View File
@@ -6,8 +6,9 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { useOfflineStore } from '../store/offlineStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { join } from '@tauri-apps/api/path';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import AlbumCard from '../components/AlbumCard';
import AlbumHeader from '../components/AlbumHeader';
import AlbumTrackList from '../components/AlbumTrackList';
@@ -44,15 +45,12 @@ export default function AlbumDetail() {
const [bio, setBio] = useState<string | null>(null);
const [bioOpen, setBioOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [isStarred, setIsStarred] = useState(false);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [offlineStorageFull, setOfflineStorageFull] = useState(false);
const { downloadAlbum, deleteAlbum } = useOfflineStore();
const offlineTracks = useOfflineStore(s => s.tracks);
const offlineAlbums = useOfflineStore(s => s.albums);
const offlineJobs = useOfflineStore(s => s.jobs);
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
const serverId = auth.activeServerId ?? '';
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
@@ -60,22 +58,32 @@ export default function AlbumDetail() {
const [albumEntityRating, setAlbumEntityRating] = useState(0);
const offlineStatus: 'none' | 'downloading' | 'cached' = (() => {
if (!album) return 'none';
const meta = offlineAlbums[`${serverId}:${album.album.id}`];
const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!offlineTracks[`${serverId}:${tid}`]);
if (isDownloaded) return 'cached';
const isDownloading = offlineJobs.some(j => j.albumId === album.album.id && (j.status === 'queued' || j.status === 'downloading'));
return isDownloading ? 'downloading' : 'none';
})();
// Derive a stable albumId for the selectors below (empty string when not yet loaded).
const albumId = album?.album.id ?? '';
const offlineProgress = (() => {
if (!album) return null;
const albumJobs = offlineJobs.filter(j => j.albumId === album.album.id);
if (albumJobs.length === 0) return null;
const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length;
return { done, total: albumJobs.length };
})();
// Selectors return primitives so Zustand only triggers a re-render when the VALUE
// actually changes — not on every `jobs` array mutation during batch downloads.
const offlineStatus = useOfflineStore((s): 'none' | 'downloading' | 'cached' => {
if (!albumId) return 'none';
const meta = s.albums[`${serverId}:${albumId}`];
const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
return isDownloaded ? 'cached' : 'none';
});
const isOfflineDownloading = useOfflineJobStore(s =>
!!albumId && s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading'))
);
const offlineProgressDone = useOfflineJobStore(s => {
if (!albumId) return 0;
return s.jobs.filter(j => j.albumId === albumId && (j.status === 'done' || j.status === 'error')).length;
});
const offlineProgressTotal = useOfflineJobStore(s => {
if (!albumId) return 0;
return s.jobs.filter(j => j.albumId === albumId).length;
});
const resolvedOfflineStatus = isOfflineDownloading ? 'downloading' : offlineStatus;
const offlineProgress = offlineProgressTotal > 0
? { done: offlineProgressDone, total: offlineProgressTotal }
: null;
useEffect(() => {
if (!id) return;
@@ -181,45 +189,22 @@ const handleEnqueueAll = () => {
if (!album) return;
const { name, id: albumId } = album.album;
// Ask for folder before starting download if not already set
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
setDownloadProgress(0);
try {
const filename = `${sanitizeFilename(name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(albumId);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const downloadId = crypto.randomUUID();
const contentLength = response.headers.get('Content-Length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
const chunks: Uint8Array<ArrayBuffer>[] = [];
if (total && response.body) {
const reader = response.body.getReader();
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
setDownloadProgress(Math.round((received / total) * 100));
}
} else {
const buffer = await response.arrayBuffer() as ArrayBuffer;
chunks.push(new Uint8Array(buffer));
setDownloadProgress(100);
}
const blob = new Blob(chunks);
const buffer = await blob.arrayBuffer();
const path = await join(folder, `${sanitizeFilename(name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
const { start, complete, fail } = useZipDownloadStore.getState();
start(downloadId, filename);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
console.error('Download failed:', e);
setDownloadProgress(null);
} finally {
setTimeout(() => setDownloadProgress(null), 60000);
fail(downloadId);
console.error('ZIP download failed:', e);
}
};
@@ -282,6 +267,12 @@ const handleEnqueueAll = () => {
const coverKey = useMemo(() => album?.album.coverArt ? coverArtCacheKey(album.album.coverArt, 400) : '', [album?.album.coverArt]);
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey);
// Must be before early returns — hooks must be called unconditionally.
const mergedStarredSongs = useMemo(() => new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false),
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
]), [starredSongs, starredOverrides]);
if (loading) return <div className="loading-center"><div className="spinner" /></div>;
if (!album) return <div className="empty-state">{t('albumDetail.notFound')}</div>;
@@ -297,7 +288,7 @@ const handleEnqueueAll = () => {
coverKey={coverKey}
resolvedCoverUrl={resolvedCoverUrl}
isStarred={isStarred}
downloadProgress={downloadProgress}
downloadProgress={null}
bio={bio}
bioOpen={bioOpen}
onToggleStar={toggleStar}
@@ -306,7 +297,7 @@ const handleEnqueueAll = () => {
onEnqueueAll={handleEnqueueAll}
onBio={handleBio}
onCloseBio={() => setBioOpen(false)}
offlineStatus={offlineStatus}
offlineStatus={resolvedOfflineStatus}
offlineProgress={offlineProgress}
onCacheOffline={handleCacheOffline}
onRemoveOffline={handleRemoveOffline}
@@ -334,10 +325,7 @@ const handleEnqueueAll = () => {
isPlaying={isPlaying}
ratings={ratings}
userRatingOverrides={userRatingOverrides}
starredSongs={new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false),
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
])}
starredSongs={mergedStarredSongs}
onPlaySong={handlePlaySong}
onRate={handleRate}
onToggleSongStar={toggleSongStar}
+13 -15
View File
@@ -6,9 +6,10 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
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';
import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
@@ -31,7 +32,7 @@ export default function Albums() {
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const { downloadAlbum } = useOfflineStore();
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -72,26 +73,23 @@ export default function Albums() {
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');
try {
const downloadId = crypto.randomUUID();
const filename = `${sanitizeFilename(album.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(album.id);
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(album.name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
done++;
start(downloadId, filename);
try {
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 () => {
+3 -1
View File
@@ -8,6 +8,7 @@ import { ArrowLeft, Users, ExternalLink, Heart, Play, Shuffle, Radio, HardDriveD
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import { lastfmGetSimilarArtists, lastfmIsConfigured } from '../api/lastfm';
@@ -69,7 +70,8 @@ export default function ArtistDetail() {
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const currentTrack = usePlayerStore(state => state.currentTrack);
const isPlaying = usePlayerStore(state => state.isPlaying);
const { downloadArtist, bulkProgress } = useOfflineStore();
const downloadArtist = useOfflineStore(s => s.downloadArtist);
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
+13 -10
View File
@@ -7,9 +7,10 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
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 PAGE_SIZE = 30;
@@ -29,7 +30,7 @@ export default function NewReleases() {
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const auth = useAuthStore();
const serverId = useAuthStore(s => s.activeServerId ?? '');
const { downloadAlbum } = useOfflineStore();
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -54,21 +55,23 @@ export default function NewReleases() {
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 () => {
+52 -48
View File
@@ -12,10 +12,12 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { usePlaylistStore } from '../store/playlistStore';
import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useAuthStore } from '../store/authStore';
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 { useZipDownloadStore } from '../store/zipDownloadStore';
import { useDragDrop } from '../contexts/DragDropContext';
import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
@@ -83,8 +85,24 @@ export default function PlaylistDetail() {
);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const { startDrag, isDragging } = useDragDrop();
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
const downloadPlaylist = useOfflineStore(s => s.downloadPlaylist);
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const isDownloading = useOfflineJobStore(s =>
!!id && s.jobs.some(j => j.albumId === id && (j.status === 'queued' || j.status === 'downloading'))
);
const isCached = useOfflineStore(s => {
if (!id) return false;
const meta = s.albums[`${activeServerId}:${id}`];
if (!meta || meta.trackIds.length === 0) return false;
return meta.trackIds.every(tid => !!s.tracks[`${activeServerId}:${tid}`]);
});
const offlineProgressDone = useOfflineJobStore(s => {
if (!id) return 0;
return s.jobs.filter(j => j.albumId === id && (j.status === 'done' || j.status === 'error')).length;
});
const offlineProgressTotal = useOfflineJobStore(s => (!id ? 0 : s.jobs.filter(j => j.albumId === id).length));
const offlineProgress = offlineProgressTotal > 0 ? { done: offlineProgressDone, total: offlineProgressTotal } : null;
const downloadFolder = useAuthStore(s => s.downloadFolder);
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
@@ -100,7 +118,9 @@ export default function PlaylistDetail() {
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const zipDownloads = useZipDownloadStore(s => s.downloads);
const [zipDownloadId, setZipDownloadId] = useState<string | null>(null);
const activeZip = zipDownloadId ? zipDownloads.find(d => d.id === zipDownloadId) : undefined;
// ── Bulk select ───────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -299,38 +319,21 @@ export default function PlaylistDetail() {
if (!playlist || !id) return;
const folder = downloadFolder || await requestDownloadFolder();
if (!folder) return;
setDownloadProgress(0);
try {
const filename = `${sanitizeFilename(playlist.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(id);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contentLength = response.headers.get('Content-Length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
const chunks: Uint8Array<ArrayBuffer>[] = [];
if (total && response.body) {
const reader = response.body.getReader();
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
setDownloadProgress(Math.round((received / total) * 100));
}
} else {
const buffer = await response.arrayBuffer() as ArrayBuffer;
chunks.push(new Uint8Array(buffer));
setDownloadProgress(100);
}
const blob = new Blob(chunks);
const buffer = await blob.arrayBuffer();
const path = await join(folder, `${sanitizeFilename(playlist.name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
const downloadId = crypto.randomUUID();
const { start, complete, fail } = useZipDownloadStore.getState();
start(downloadId, filename);
setZipDownloadId(downloadId);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
console.error('Download failed:', e);
setDownloadProgress(null);
} finally {
setTimeout(() => setDownloadProgress(null), 60000);
fail(downloadId);
console.error('ZIP download failed:', e);
}
};
@@ -587,33 +590,34 @@ export default function PlaylistDetail() {
>
<Search size={16} /> {t('playlists.addSongs')}
</button>
{songs.length > 0 && id && (() => {
const isDownloading = isAlbumDownloading(id);
const isCached = isAlbumDownloaded(id, activeServerId);
const progress = isDownloading ? getAlbumProgress(id) : null;
return (
{songs.length > 0 && id && (
<button
className="btn btn-ghost"
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
disabled={isDownloading}
onClick={() => { if (playlist) downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId); }}
onClick={() => {
if (isCached) {
deleteAlbum(id, activeServerId);
} else if (playlist) {
downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId);
}
}}
data-tooltip={isDownloading
? t('albumDetail.offlineDownloading', { n: progress?.done ?? 0, total: progress?.total ?? 0 })
: isCached ? t('playlists.offlineCached') : t('playlists.cacheOffline')}
? t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })
: isCached ? t('playlists.removeOffline') : t('playlists.cacheOffline')}
>
{isDownloading
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
: isCached ? <Check size={16} /> : <HardDriveDownload size={16} />}
: isCached ? <Trash2 size={16} /> : <HardDriveDownload size={16} />}
</button>
);
})()}
)}
{songs.length > 0 && (
downloadProgress !== null ? (
activeZip && !activeZip.done && !activeZip.error ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
<div className="download-progress-fill" style={{ width: `${activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : 0}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
<span className="download-progress-pct">{activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%</span>
</div>
) : (
<button className="btn btn-ghost" onClick={handleDownload} data-tooltip={t('playlists.downloadZip')}>
+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 () => {
+46
View File
@@ -0,0 +1,46 @@
import { create } from 'zustand';
export interface DownloadJob {
trackId: string;
albumId: string;
albumName: string;
trackTitle: string;
trackIndex: number;
totalTracks: number;
status: 'queued' | 'downloading' | 'done' | 'error';
}
interface OfflineJobState {
jobs: DownloadJob[];
bulkProgress: Record<string, { done: number; total: number }>;
cancelDownload: (albumId: string) => void;
cancelAllDownloads: () => void;
}
// Module-level cancellation set — checked by downloadAlbum before each batch.
export const cancelledDownloads = new Set<string>();
export const useOfflineJobStore = create<OfflineJobState>()((set, get) => ({
jobs: [],
bulkProgress: {},
cancelDownload: (albumId) => {
cancelledDownloads.add(albumId);
// Remove queued (not yet started) jobs immediately so the counter drops.
set(state => ({
jobs: state.jobs.filter(j => !(j.albumId === albumId && j.status === 'queued')),
}));
},
cancelAllDownloads: () => {
const unique = [...new Set(
get().jobs
.filter(j => j.status === 'queued' || j.status === 'downloading')
.map(j => j.albumId),
)];
unique.forEach(id => cancelledDownloads.add(id));
set(state => ({
jobs: state.jobs.filter(j => j.status !== 'queued'),
}));
},
}));
+81 -58
View File
@@ -5,6 +5,7 @@ import { buildStreamUrl, getArtist, getAlbum } from '../api/subsonic';
import type { SubsonicSong } from '../api/subsonic';
import { useAuthStore } from './authStore';
import { showToast } from '../utils/toast';
import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore';
export interface OfflineTrackMeta {
id: string;
@@ -38,22 +39,12 @@ export interface OfflineAlbumMeta {
type?: 'album' | 'playlist' | 'artist';
}
export interface DownloadJob {
trackId: string;
albumId: string;
albumName: string;
trackTitle: string;
trackIndex: number;
totalTracks: number;
status: 'queued' | 'downloading' | 'done' | 'error';
}
// Re-export for components that import DownloadJob from offlineStore.
export type { DownloadJob } from './offlineJobStore';
interface OfflineState {
tracks: Record<string, OfflineTrackMeta>; // key: `${serverId}:${trackId}`
albums: Record<string, OfflineAlbumMeta>; // key: `${serverId}:${albumId}`
jobs: DownloadJob[];
/** Progress for bulk (playlist / artist) downloads. Key = playlistId or artistId. */
bulkProgress: Record<string, { done: number; total: number }>;
isDownloaded: (trackId: string, serverId: string) => boolean;
isAlbumDownloaded: (albumId: string, serverId: string) => boolean;
@@ -81,8 +72,6 @@ export const useOfflineStore = create<OfflineState>()(
(set, get) => ({
tracks: {},
albums: {},
jobs: [],
bulkProgress: {},
isDownloaded: (trackId, serverId) =>
!!get().tracks[`${serverId}:${trackId}`],
@@ -94,7 +83,7 @@ export const useOfflineStore = create<OfflineState>()(
},
isAlbumDownloading: (albumId) =>
get().jobs.some(
useOfflineJobStore.getState().jobs.some(
j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading')
),
@@ -113,22 +102,40 @@ export const useOfflineStore = create<OfflineState>()(
},
getAlbumProgress: (albumId) => {
const albumJobs = get().jobs.filter(j => j.albumId === albumId);
const albumJobs = useOfflineJobStore.getState().jobs.filter(j => j.albumId === albumId);
if (albumJobs.length === 0) return null;
const done = albumJobs.filter(j => j.status === 'done' || j.status === 'error').length;
return { done, total: albumJobs.length };
},
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId, type = 'album') => {
const CONCURRENCY = 2;
// Frontend fires up to 8 invoke calls at a time so Rust always has work queued.
// The backend Semaphore (MAX_DL_CONCURRENCY = 4) is the real throttle —
// at most 4 HTTP streams run simultaneously regardless of this value.
const CONCURRENCY = 8;
const trackIds = songs.map(s => s.id);
const jobStore = useOfflineJobStore;
// Register album shell + queue jobs
// Pre-flight: verify the target directory is accessible before queuing anything.
const customDir = useAuthStore.getState().offlineDownloadDir || null;
if (customDir) {
const ok = await invoke<boolean>('check_dir_accessible', { path: customDir }).catch(() => false);
if (!ok) {
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
return;
}
}
// Register album in persisted store — 1 localStorage write.
set(state => ({
albums: {
...state.albums,
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds, type },
},
}));
// Queue jobs in the non-persisted job store — zero localStorage writes.
jobStore.setState(state => ({
jobs: [
...state.jobs.filter(j => j.albumId !== albumId),
...songs.map((s, i) => ({
@@ -143,36 +150,57 @@ export const useOfflineStore = create<OfflineState>()(
],
}));
// Download in batches of CONCURRENCY
// Accumulate completed tracks locally — persisted in ONE write at the very end.
const completedTracks: Record<string, OfflineTrackMeta> = {};
for (let i = 0; i < songs.length; i += CONCURRENCY) {
// Abort if the user cancelled this download.
if (cancelledDownloads.has(albumId)) {
cancelledDownloads.delete(albumId);
jobStore.setState(state => ({ jobs: state.jobs.filter(j => j.albumId !== albumId) }));
return;
}
const batch = songs.slice(i, i + CONCURRENCY);
await Promise.all(
batch.map(async song => {
set(state => ({
const batchIds = new Set(batch.map(s => s.id));
// Mark batch as downloading — job store only, no localStorage write.
jobStore.setState(state => ({
jobs: state.jobs.map(j =>
j.trackId === song.id && j.albumId === albumId
j.albumId === albumId && batchIds.has(j.trackId)
? { ...j, status: 'downloading' }
: j,
),
}));
// Run all downloads concurrently, collect results without touching any store.
const results = await Promise.all(
batch.map(async song => {
const suffix = song.suffix || 'mp3';
const url = buildStreamUrl(song.id);
const customDir = useAuthStore.getState().offlineDownloadDir || null;
try {
const localPath = await invoke<string>('download_track_offline', {
trackId: song.id,
serverId,
url,
url: buildStreamUrl(song.id),
suffix,
customDir,
});
return { song, suffix, localPath, error: null as string | null };
} catch (err) {
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
if (msg === 'VOLUME_NOT_FOUND' && !cancelledDownloads.has(albumId)) {
cancelledDownloads.add(albumId);
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
}
return { song, suffix, localPath: null as string | null, error: msg };
}
}),
);
set(state => ({
tracks: {
...state.tracks,
[`${serverId}:${song.id}`]: {
// Accumulate completed tracks locally (no store write yet).
for (const { song, suffix, localPath } of results) {
if (localPath) {
completedTracks[`${serverId}:${song.id}`] = {
id: song.id,
serverId,
localPath,
@@ -191,34 +219,28 @@ export const useOfflineStore = create<OfflineState>()(
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
cachedAt: new Date().toISOString(),
},
},
jobs: state.jobs.map(j =>
j.trackId === song.id && j.albumId === albumId
? { ...j, status: 'done' }
: j,
),
}));
} catch (err) {
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
if (msg === 'VOLUME_NOT_FOUND') {
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
};
}
set(state => ({
jobs: state.jobs.map(j =>
j.trackId === song.id && j.albumId === albumId
? { ...j, status: 'error' }
: j,
),
}));
}
}),
);
}
// Clear completed jobs after a short delay
// Update job statuses — job store only, no localStorage write.
const resultMap = new Map(results.map(r => [r.song.id, r]));
jobStore.setState(state => ({
jobs: state.jobs.map(j => {
if (j.albumId !== albumId) return j;
const r = resultMap.get(j.trackId);
if (!r) return j;
return { ...j, status: r.localPath ? 'done' : 'error' };
}),
}));
}
// Persist all completed tracks in ONE localStorage write.
set(state => ({ tracks: { ...state.tracks, ...completedTracks } }));
// Clear completed jobs after a short delay.
setTimeout(() => {
set(state => ({
jobStore.setState(state => ({
jobs: state.jobs.filter(
j => j.albumId !== albumId || (j.status !== 'done' && j.status !== 'error'),
),
@@ -236,12 +258,13 @@ export const useOfflineStore = create<OfflineState>()(
},
downloadArtist: async (artistId, artistName, serverId) => {
const jobStore = useOfflineJobStore;
let albums: { id: string; name: string; artist: string; coverArt?: string; year?: number }[] = [];
try {
const res = await getArtist(artistId);
albums = res.albums;
} catch { return; }
set(state => ({
jobStore.setState(state => ({
bulkProgress: { ...state.bulkProgress, [artistId]: { done: 0, total: albums.length } },
}));
for (let i = 0; i < albums.length; i++) {
@@ -250,12 +273,12 @@ export const useOfflineStore = create<OfflineState>()(
const { songs } = await getAlbum(album.id);
await get().downloadAlbum(album.id, album.name, album.artist || artistName, album.coverArt, album.year, songs, serverId, 'artist');
} catch { /* skip failed album */ }
set(state => ({
jobStore.setState(state => ({
bulkProgress: { ...state.bulkProgress, [artistId]: { done: i + 1, total: albums.length } },
}));
}
setTimeout(() => {
set(state => {
jobStore.setState(state => {
const { [artistId]: _removed, ...rest } = state.bulkProgress;
return { bulkProgress: rest };
});
+46
View File
@@ -0,0 +1,46 @@
import { create } from 'zustand';
export interface ZipDownload {
id: string;
filename: string;
bytes: number;
/** null = Content-Length unbekannt (Navidrome on-the-fly ZIP) */
total: number | null;
done: boolean;
error: boolean;
}
interface ZipDownloadState {
downloads: ZipDownload[];
start: (id: string, filename: string) => void;
updateProgress: (id: string, bytes: number, total: number | null) => void;
complete: (id: string) => void;
fail: (id: string) => void;
dismiss: (id: string) => void;
}
export const useZipDownloadStore = create<ZipDownloadState>((set) => ({
downloads: [],
start: (id, filename) => set(state => ({
downloads: [...state.downloads, { id, filename, bytes: 0, total: null, done: false, error: false }],
})),
updateProgress: (id, bytes, total) => set(state => ({
downloads: state.downloads.map(d =>
d.id === id ? { ...d, bytes, total: total ?? d.total } : d
),
})),
complete: (id) => set(state => ({
downloads: state.downloads.map(d => d.id === id ? { ...d, done: true } : d),
})),
fail: (id) => set(state => ({
downloads: state.downloads.map(d => d.id === id ? { ...d, error: true } : d),
})),
dismiss: (id) => set(state => ({
downloads: state.downloads.filter(d => d.id !== id),
})),
}));
+122
View File
@@ -6887,3 +6887,125 @@
color: var(--text-muted);
margin-left: auto;
}
/* ─── ZIP Download Overlay ─── */
.zip-dl-overlay {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9000;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.zip-dl-item {
pointer-events: all;
width: 280px;
padding: 10px 12px;
border-radius: var(--radius-md);
background: var(--bg-sidebar);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
gap: 6px;
animation: zip-dl-slide-in 0.2s ease;
}
@keyframes zip-dl-slide-in {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
.zip-dl-item.zip-dl-done {
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
opacity: 0.8;
}
.zip-dl-item.zip-dl-error {
border-color: color-mix(in srgb, var(--danger, #e05555) 40%, transparent);
opacity: 0.85;
}
.zip-dl-header {
display: flex;
align-items: center;
gap: 7px;
color: var(--accent);
font-size: 12px;
}
.zip-dl-item.zip-dl-error .zip-dl-header {
color: var(--danger, #e05555);
}
.zip-dl-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
font-size: 12px;
}
.zip-dl-close {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
padding: 0;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
}
.zip-dl-close:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.zip-dl-info {
font-size: 11px;
color: var(--text-muted);
padding-left: 20px;
}
.zip-dl-track {
height: 3px;
border-radius: 2px;
background: var(--bg-hover);
overflow: hidden;
position: relative;
}
.zip-dl-fill {
height: 100%;
border-radius: 2px;
background: var(--accent);
transition: width 0.4s linear;
}
/* Indeterminate sliding animation */
.zip-dl-indeterminate::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 40%;
border-radius: 2px;
background: var(--accent);
animation: zip-dl-shimmer 1.4s ease-in-out infinite;
}
@keyframes zip-dl-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
+22
View File
@@ -729,6 +729,28 @@
padding: 6px;
}
.sidebar-offline-cancel {
margin-left: auto;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--accent);
cursor: pointer;
opacity: 0.6;
padding: 0;
transition: opacity 0.15s, background 0.15s;
}
.sidebar-offline-cancel:hover {
opacity: 1;
background: color-mix(in srgb, var(--accent) 20%, transparent);
}
@keyframes spin-slow {
to { transform: rotate(360deg); }
}