mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b239892ef | |||
| ba670bd1e8 | |||
| c1e57b4c06 | |||
| fbe68116cc | |||
| dba0c26480 | |||
| 3643a78cd6 | |||
| 1e4b851e9e | |||
|
37799fb861
|
|||
| 099516121e | |||
| 9047a44480 | |||
| 47fcade3b3 | |||
| d832dfb253 | |||
| 041d946b58 | |||
| 64d8b1cbd3 | |||
| 5848c621fd | |||
| 0a0dde057d | |||
| 0119e27f6d | |||
| c7adb599ee | |||
| d6546e12ca | |||
| f0438cae5e | |||
| e64d151079 | |||
| f0b423ddc4 |
+65
-1
@@ -5,6 +5,70 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.34.6] - 2026-04-08
|
||||
|
||||
> I'm sorry this is already the third release today — every time we shipped a critical fix, another critical issue surfaced. Hopefully this one holds. 🤞
|
||||
|
||||
### 🚨 Critical Fix
|
||||
|
||||
- **ZIP downloads no longer freeze the UI**: All ZIP downloads (Album Detail, Playlist Detail, Albums, New Releases, Random Albums) previously buffered the entire file in the JS heap via `fetch + blob + arrayBuffer`, which caused the app to become completely unresponsive for large downloads (e.g. a 600-song, 7 GB playlist). Downloads now stream directly to disk via the Rust backend (`invoke('download_zip')`), matching the existing single-album download behavior. Progress is shown in the download overlay (bottom right).
|
||||
|
||||
- **Offline cache downloads no longer freeze the UI**: Caching a large playlist (600+ songs) triggered up to ~1,200 synchronous `localStorage.setItem` calls as Zustand's `persist` middleware wrote on every state update. Transient download job state has been moved to a new non-persisted store (`offlineJobStore`), reducing localStorage writes for an entire download to **2** regardless of playlist size.
|
||||
|
||||
### Added
|
||||
|
||||
- **Playlist offline toggle**: When a playlist is already cached offline, clicking the cache button now removes it from the offline cache (shown with a red trash icon) instead of re-downloading it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Home page — "Recently Added" section title** now links to `/new-releases` instead of `/albums`.
|
||||
|
||||
---
|
||||
|
||||
## [1.34.5] - 2026-04-08
|
||||
|
||||
### 🚨 Critical Fix
|
||||
|
||||
- **Massive API request flood fixed** *(closes [#133](https://github.com/Psychotoxical/psysonic/issues/133))*: Psysonic was generating 15,000+ background requests per day, filling reverse-proxy access logs (Traefik, nginx) and in some cases crashing the proxy entirely. Four root causes identified and resolved:
|
||||
- **Now Playing polling**: Was firing every 10 seconds unconditionally — even when minimized or the dropdown was closed. Now only polls while the dropdown is open, and respects the Page Visibility API to pause immediately when the window is hidden.
|
||||
- **Connection check interval**: Reduced from every 30 seconds to every **120 seconds** (4× reduction).
|
||||
- **Queue sync debounce**: Increased from 1.5 s to **5 s**, preventing request bursts when skipping rapidly through tracks.
|
||||
- **Rating prefetch cache**: Artist and album ratings are now cached in memory for **7 minutes**. Repeated page loads (Random Albums, Random Mix) no longer re-fetch ratings that were just retrieved.
|
||||
|
||||
### Added
|
||||
|
||||
- **Theme Scheduler** *(Settings → Appearance)*: Automatically switches the active theme at configurable times of day. Two time slots (e.g. a light theme during the day, a dark one at night). English locale displays hours in 12-hour AM/PM format; all other languages use 24-hour format.
|
||||
|
||||
- **Theme Scheduler hint**: When the scheduler is active, a notice appears at the top of the Theme Picker explaining why manually selecting a theme has no immediate effect.
|
||||
|
||||
- **UI Scale** *(Settings → Appearance)*: Adjust the global interface scale (80 % – 125 %) without changing the system font size.
|
||||
|
||||
- **Folder Browser**: New sidebar section with Miller-columns directory navigation. Browse the server's music folder tree and play or queue folders directly.
|
||||
|
||||
- **Seekbar — Waveform fade edges**: The Waveform seekbar style now fades out at both ends, giving it a cleaner, less abrupt look.
|
||||
|
||||
- **Cover art fallback logo**: When a cover art image fails to load (broken URL, server error), the Psysonic logo is shown as a placeholder instead of a broken image icon.
|
||||
|
||||
- **Tiling WM support** *(PR [#134](https://github.com/Psychotoxical/psysonic/pull/134))*: On tiling window managers (Hyprland, Sway, i3, bspwm, AwesomeWM, etc.) the custom title bar is automatically hidden — the WM manages window decorations. The title bar toggle in Settings is also hidden on tiling WMs. Detection is based on environment variables (`HYPRLAND_INSTANCE_SIGNATURE`, `SWAYSOCK`, `I3SOCK`, `XDG_CURRENT_DESKTOP`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Custom title bar disabled by default**: New installations start with the native OS title bar. Existing users keep their saved preference.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Custom title bar (Linux) — drag & resize**: Window dragging via the title bar now works correctly (missing Tauri `core:window:allow-start-dragging` capability was silently blocking it). CSS resize grips are now shown at the bottom corners to compensate for the removed native GTK grips. The title bar no longer misplaces itself when the window is resized to a small width (mobile-grid layout now includes the title bar row).
|
||||
|
||||
- **Fullscreen Player — accent color delay**: The dynamic accent color extracted from album artwork now appears in ~200–300 ms instead of up to 18 seconds. The previous implementation queued the cover fetch behind up to 5 concurrent image loads via the app-wide image cache. It now fetches the cover directly and independently. The extracted color is also cached per cover ID, so switching between tracks on the same album is instant.
|
||||
|
||||
- **Artist Detail page — slow initial render** *(closes [#132](https://github.com/Psychotoxical/psysonic/issues/132))*: Artist info and biography are now fetched independently of the main artist data, so the page renders immediately and the bio fades in once available. Previously, a slow `getArtistInfo` response blocked the entire page from rendering.
|
||||
|
||||
- **Seekbar — Pulse Wave & Retro Tape styles**: Pulse Wave no longer leaves a stray connecting line at the playhead position. Retro Tape's rolling wheel is now anchored at the playhead instead of the center of the bar.
|
||||
|
||||
- **Statistics — Top Rated Songs/Artists sections removed**: These sections were incorrectly added in v1.34.4 and have been removed. All other rating features from that release remain fully intact.
|
||||
|
||||
---
|
||||
|
||||
## [1.34.4] - 2026-04-08
|
||||
|
||||
### Added
|
||||
@@ -13,7 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Song ratings — context menu & player bar**: Songs can additionally be rated directly from the **right-click context menu** and from the **player bar** (below the artist name), with optimistic updates reflected immediately across all views.
|
||||
|
||||
- **Skip-to-1★** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Automatically assigns a 1-star rating when a song is manually skipped before a configurable playback threshold (default: 20 s). Can be enabled and adjusted in Settings → Ratings.
|
||||
- **Skip-to-1★** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Automatically assigns a 1-star rating to a song after it has been manually skipped a configurable number of consecutive times. This skip count threshold can be enabled and adjusted in Settings → Ratings.
|
||||
|
||||
- **Mix minimum rating filter** *(PR [#130](https://github.com/Psychotoxical/psysonic/pull/130))*: Random Mix and Home Quick Mix can now be filtered by minimum rating per entity type (song / album / artist). Configure thresholds in Settings → Ratings.
|
||||
|
||||
|
||||
@@ -32,23 +32,31 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎨 **Gorgeous UI**: A large selection of beautiful, lean themes for every taste — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox), Operating Systems, Games, Movies, Series, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations.
|
||||
- 🎨 **Gorgeous UI**: 67 beautiful themes across 8 groups — Open Source Classics (Catppuccin, Nord, Gruvbox, Nightfox, Dracula), Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, and Mediaplayer — with smooth glassmorphism effects and micro-animations. A time-based **Theme Scheduler** can automatically switch between a day and night theme.
|
||||
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, and Chinese.
|
||||
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, Dutch, Chinese, Norwegian, and Russian.
|
||||
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
|
||||
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
|
||||
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll, line highlighting, click-to-seek, and plain-text fallback.
|
||||
- 🎤 **Synchronized Lyrics**: Lyrics pane in the sidebar and fullscreen player — powered by LRCLIB and your Navidrome server. Synced lyrics auto-scroll with line highlighting and click-to-seek; plain-text fallback for unsynced tracks.
|
||||
- 📻 **Smart Radio**: Start a Radio session from any song or artist. Playback begins instantly from top local tracks while similar artist tracks (via Last.fm) load in the background. Radio queues reload proactively so sessions never run dry.
|
||||
- ♾️ **Infinite Queue**: When the queue runs out with Repeat off, Psysonic silently appends more random tracks (optionally filtered by genre) so playback never stops. Auto-added tracks appear below a clear `— Auto —` divider.
|
||||
- 🎛️ **10-Band Graphic EQ**: Built-in EQ with presets and the ability to save custom presets. **AutoEQ** support lets you load headphone correction profiles automatically.
|
||||
- 🔀 **Gapless & Crossfade**: True gapless playback and configurable crossfade between tracks (mutually exclusive).
|
||||
- 📻 **Internet Radio**: Built-in internet radio player — browse and play any ICY/HLS stream directly within Psysonic.
|
||||
- ⭐ **Ratings**: Rate songs, albums, and artists with 1–5 stars via the context menu, player bar, or album detail view. Supports the OpenSubsonic ratings extension. Auto-rate-down songs you skip repeatedly (configurable threshold). Filter Random Mix and Random Albums by minimum star rating.
|
||||
- 🖥️ **Fullscreen Player**: A dedicated fullscreen view with album art, animated lyrics overlay, and artist image — toggled with a single click.
|
||||
- 📋 **Playlist Management**: Create, edit, rename, and delete playlists. Drag-and-drop track reordering, song search, and smart suggestions right inside the playlist view.
|
||||
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
|
||||
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
|
||||
- 〰️ **Waveform Seekbar**: Canvas-based waveform with a blue-to-mauve gradient and glow effect — click or drag anywhere to seek.
|
||||
- 💿 **Album & Artist Views**: Beautiful grid displays, multi-select album actions, and detailed artist pages with related albums.
|
||||
- 〰️ **Multi-Style Seekbar**: 10 canvas-drawn seekbar styles — Waveform, Bar, Thick Bar, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, and Retro Tape.
|
||||
- 🎛️ **Queue Management**: Drag & drop reordering, shuffle, playlist saving/loading, and server-side queue synchronization.
|
||||
- ⌨️ **Configurable Keybindings**: Rebind any playback action (play/pause, next, seek, volume…) directly in Settings.
|
||||
- 🔤 **Font Picker**: 10 UI fonts to choose from in Settings → Appearance.
|
||||
- 🔤 **Font Picker & UI Scale**: 10 UI fonts and a global zoom slider (80–150%) to match your display and taste.
|
||||
- 🎼 **Random Mix**: Generate a random playlist from your entire library. Filter by keyword or pick a Super Genre (Metal, Rock, Electronic, Jazz…) for a focused mix with progressive loading.
|
||||
- 🏷️ **Genres**: Browse your entire library by genre — coloured cards sorted by album count with a dedicated album view per genre. Multi-select genre filter available on Albums, New Releases, and Random Albums pages.
|
||||
- 🔔 **System Tray**: Minimize Psysonic to the system tray. Play/Pause, Prev, Next, and Show/Hide controls available from the tray icon.
|
||||
- 💾 **Backup & Restore**: Export and import all your settings, themes, and server profiles in one click.
|
||||
- 🔄 **In-App Auto-Update**: Checks for new releases on startup. macOS and Windows can install and relaunch directly in-app; Linux users get a link to the GitHub release page.
|
||||
- 🖥️ **Cross-Platform**: Available natively for Windows, macOS, and Linux (Arch AUR, .deb, .rpm).
|
||||
|
||||
@@ -57,30 +65,41 @@ Designed specifically for users hosting their own music via Navidrome or other S
|
||||
### ✅ Completed
|
||||
- [x] Native Rust/rodio audio engine (replaces Howler.js)
|
||||
- [x] 10-band graphic EQ with built-in and custom presets
|
||||
- [x] AutoEQ — automatic headphone correction profile loader
|
||||
- [x] Crossfade between tracks
|
||||
- [x] Replay Gain (track + album mode)
|
||||
- [x] Gapless playback
|
||||
- [x] Waveform seekbar
|
||||
- [x] Multi-style seekbar (10 styles: Waveform, Bar, Thick, Segmented, Line+Dot, Neon, Pulse Wave, Particle Trail, Liquid Fill, Retro Tape)
|
||||
- [x] Last.fm scrobbling, Now Playing & love/unlove
|
||||
- [x] Similar Artists via Last.fm, filtered to library
|
||||
- [x] Statistics — Last.fm top charts & recent scrobbles
|
||||
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll, click-to-seek)
|
||||
- [x] Statistics — Last.fm top charts, recent scrobbles, top-rated songs & artists
|
||||
- [x] Synchronized lyrics via LRCLIB and Navidrome server (in-sidebar + fullscreen, auto-scroll, click-to-seek)
|
||||
- [x] Smart Radio with proactive queue loading
|
||||
- [x] Infinite Queue (random auto-fill when queue runs out)
|
||||
- [x] OGG/Vorbis native playback
|
||||
- [x] Internet Radio (ICY/HLS streams)
|
||||
- [x] In-app auto-updater (macOS + Windows)
|
||||
- [x] Multi-server support
|
||||
- [x] IndexedDB image caching
|
||||
- [x] Random Mix with server-native Genre Mix (top genres by song count, shuffleable)
|
||||
- [x] Advanced Search (text + genre + year + result-type filters)
|
||||
- [x] Large theme library across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
|
||||
- [x] Internationalization (English, German, French, Dutch, Chinese)
|
||||
- [x] 67 themes across 8 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Social Media, Psysonic originals, Mediaplayer
|
||||
- [x] Time-based Theme Scheduler (auto day/night theme switching)
|
||||
- [x] Internationalization (English, German, French, Dutch, Chinese, Norwegian, Russian)
|
||||
- [x] AUR package (Arch / CachyOS)
|
||||
- [x] Configurable keybindings
|
||||
- [x] Font picker (10 UI fonts)
|
||||
- [x] Font picker (10 UI fonts) + global UI scale slider
|
||||
- [x] Playlist management (create, edit, delete, drag-and-drop reorder, suggestions)
|
||||
- [x] Fullscreen player with synced lyrics overlay
|
||||
- [x] System tray icon with playback controls
|
||||
- [x] Song / Album / Artist ratings (1–5 stars, OpenSubsonic extension)
|
||||
- [x] Auto-rate-down on repeated skips + minimum-rating filter for mixes
|
||||
- [x] Album multi-select actions
|
||||
- [x] Custom Linux titlebar
|
||||
- [x] Backup & Restore (settings export/import)
|
||||
|
||||
### 📋 Planned
|
||||
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 60+ themes
|
||||
- [ ] Theme contrast & legibility audit — systematic review of text/background contrast ratios across all 67 themes
|
||||
- [ ] Accessibility (a11y) — keyboard navigation, screen reader support, ARIA labels
|
||||
- [ ] More languages
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "psysonic",
|
||||
"version": "1.34.4",
|
||||
"version": "1.34.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
|
||||
pkgname=psysonic
|
||||
pkgver=1.34.3
|
||||
pkgver=1.34.6
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+1
-1
@@ -3339,7 +3339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "psysonic"
|
||||
version = "1.34.4"
|
||||
version = "1.34.5"
|
||||
dependencies = [
|
||||
"biquad",
|
||||
"discord-rich-presence",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "psysonic"
|
||||
version = "1.34.4"
|
||||
version = "1.34.6"
|
||||
description = "Psysonic Desktop Music Player"
|
||||
authors = []
|
||||
license = ""
|
||||
@@ -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"
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-create",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"process:allow-restart"
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-start-dragging","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
|
||||
+202
-9
@@ -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,
|
||||
@@ -816,6 +957,53 @@ fn stop_audio_engine(app: &tauri::AppHandle) {
|
||||
if let Some(sink) = cur.sink.take() { sink.stop(); }
|
||||
}
|
||||
|
||||
/// Returns `true` if running under a tiling window manager (Hyprland, Sway, i3,
|
||||
/// bspwm, AwesomeWM, Openbox, etc.). Detection is based on environment variables
|
||||
/// set by the compositor / DE.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn is_tiling_wm() -> bool {
|
||||
// Direct compositor signatures (most reliable).
|
||||
let direct = [
|
||||
"HYPRLAND_INSTANCE_SIGNATURE", // Hyprland
|
||||
"SWAYSOCK", // Sway
|
||||
"I3SOCK", // i3
|
||||
]
|
||||
.iter()
|
||||
.any(|&var| std::env::var_os(var).is_some());
|
||||
|
||||
if direct {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check XDG_CURRENT_DESKTOP for known tiling WMs.
|
||||
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
|
||||
let desktop = desktop.to_lowercase();
|
||||
let tiling_wms = [
|
||||
"hyprland", "sway", "i3", "bspwm", "awesome", "openbox",
|
||||
"xmonad", "dwm", "qtile", "herbstluftwm", "leftwm",
|
||||
];
|
||||
if tiling_wms.iter().any(|&wm| desktop.contains(wm)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Tauri command: lets the frontend know whether we're running under a tiling
|
||||
/// WM so it can decide whether to render the custom TitleBar component.
|
||||
#[tauri::command]
|
||||
fn is_tiling_wm_cmd() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
is_tiling_wm()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
let (audio_engine, _audio_thread) = audio::create_engine();
|
||||
|
||||
@@ -823,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())
|
||||
@@ -840,8 +1029,9 @@ pub fn run() {
|
||||
|
||||
.setup(|app| {
|
||||
// ── Custom title bar on Linux ─────────────────────────────────
|
||||
// Remove OS window decorations so the React TitleBar component
|
||||
// takes over. macOS and Windows keep their native decorations.
|
||||
// Remove OS window decorations on all Linux so the React TitleBar
|
||||
// can take over. The frontend checks is_tiling_wm() to decide
|
||||
// whether to actually render the TitleBar (hidden on tiling WMs).
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use tauri::Manager;
|
||||
@@ -976,6 +1166,7 @@ pub fn run() {
|
||||
greet,
|
||||
exit_app,
|
||||
set_window_decorations,
|
||||
is_tiling_wm_cmd,
|
||||
register_global_shortcut,
|
||||
unregister_global_shortcut,
|
||||
mpris_set_metadata,
|
||||
@@ -1013,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");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Psysonic",
|
||||
"version": "1.34.4",
|
||||
"version": "1.34.6",
|
||||
"identifier": "dev.psysonic.player",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+36
-7
@@ -34,6 +34,7 @@ import AdvancedSearch from './pages/AdvancedSearch';
|
||||
import Playlists from './pages/Playlists';
|
||||
import PlaylistDetail from './pages/PlaylistDetail';
|
||||
import InternetRadio from './pages/InternetRadio';
|
||||
import FolderBrowser from './pages/FolderBrowser';
|
||||
import NowPlayingPage from './pages/NowPlaying';
|
||||
import FullscreenPlayer from './components/FullscreenPlayer';
|
||||
import ContextMenu from './components/ContextMenu';
|
||||
@@ -61,10 +62,13 @@ import { useOfflineStore } from './store/offlineStore';
|
||||
import { initHotCachePrefetch } from './hotCachePrefetch';
|
||||
import { usePlayerStore, initAudioListeners } from './store/playerStore';
|
||||
import { useThemeStore } from './store/themeStore';
|
||||
import { useThemeScheduler } from './hooks/useThemeScheduler';
|
||||
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();
|
||||
@@ -76,6 +80,12 @@ function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
|
||||
const [isTilingWm, setIsTilingWm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
@@ -106,10 +116,12 @@ function AppShell() {
|
||||
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
|
||||
|
||||
// Sync custom titlebar preference with native decorations on Linux
|
||||
// On tiling WMs decorations are always off (no native title bar to replace).
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke('set_window_decorations', { enabled: !useCustomTitlebar }).catch(() => {});
|
||||
}, [useCustomTitlebar]);
|
||||
const enabled = isTilingWm ? false : !useCustomTitlebar;
|
||||
invoke('set_window_decorations', { enabled }).catch(() => {});
|
||||
}, [useCustomTitlebar, isTilingWm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !activeServerId) return;
|
||||
@@ -286,14 +298,14 @@ function AppShell() {
|
||||
className="app-shell"
|
||||
data-mobile={isMobile || undefined}
|
||||
data-mobile-player={isMobilePlayer || undefined}
|
||||
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen) || undefined}
|
||||
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
|
||||
style={{
|
||||
'--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'),
|
||||
'--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px')
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && <TitleBar />}
|
||||
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && <TitleBar />}
|
||||
{!isMobile && (
|
||||
<Sidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
@@ -351,6 +363,7 @@ function AppShell() {
|
||||
<Route path="/playlists" element={<Playlists />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetail />} />
|
||||
<Route path="/radio" element={<InternetRadio />} />
|
||||
<Route path="/folders" element={<FolderBrowser />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
@@ -386,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);
|
||||
@@ -504,18 +526,24 @@ function TauriEventBridge() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const theme = useThemeStore(s => s.theme);
|
||||
useThemeStore(s => s.theme); // keep subscription so re-render on manual change
|
||||
const effectiveTheme = useThemeScheduler();
|
||||
const font = useFontStore(s => s.font);
|
||||
const uiScale = useFontStore(s => s.uiScale);
|
||||
const [exportPickerOpen, setExportPickerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||
}, [effectiveTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-font', font);
|
||||
}, [font]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.zoom = String(uiScale);
|
||||
}, [uiScale]);
|
||||
|
||||
useEffect(() => {
|
||||
return initAudioListeners();
|
||||
}, []);
|
||||
@@ -592,6 +620,7 @@ export default function App() {
|
||||
/>
|
||||
</Routes>
|
||||
{exportPickerOpen && <ExportPickerModal onConfirm={handleExport} onClose={() => setExportPickerOpen(false)} />}
|
||||
<ZipDownloadOverlay />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
+97
-6
@@ -181,6 +181,68 @@ export interface SubsonicArtistInfo {
|
||||
}
|
||||
|
||||
// ─── API Methods ──────────────────────────────────────────────
|
||||
export interface SubsonicDirectoryEntry {
|
||||
id: string;
|
||||
parent?: string;
|
||||
title: string;
|
||||
isDir: boolean;
|
||||
album?: string;
|
||||
artist?: string;
|
||||
albumId?: string;
|
||||
artistId?: string;
|
||||
coverArt?: string;
|
||||
duration?: number;
|
||||
track?: number;
|
||||
year?: number;
|
||||
bitRate?: number;
|
||||
suffix?: string;
|
||||
size?: number;
|
||||
genre?: string;
|
||||
starred?: string;
|
||||
userRating?: number;
|
||||
}
|
||||
|
||||
export interface SubsonicDirectory {
|
||||
id: string;
|
||||
parent?: string;
|
||||
name: string;
|
||||
child: SubsonicDirectoryEntry[];
|
||||
}
|
||||
|
||||
export async function getMusicDirectory(id: string): Promise<SubsonicDirectory> {
|
||||
const data = await api<{ directory: { id: string; parent?: string; name: string; child?: SubsonicDirectoryEntry | SubsonicDirectoryEntry[] } }>(
|
||||
'getMusicDirectory.view',
|
||||
{ id },
|
||||
);
|
||||
const dir = data.directory;
|
||||
const raw = dir.child;
|
||||
const child: SubsonicDirectoryEntry[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
||||
return { id: dir.id, parent: dir.parent, name: dir.name, child };
|
||||
}
|
||||
|
||||
/** Returns the top-level artist/directory entries for a music folder root.
|
||||
* Music folder IDs from getMusicFolders() are NOT valid getMusicDirectory IDs —
|
||||
* use getIndexes.view with musicFolderId instead. */
|
||||
export async function getMusicIndexes(musicFolderId: string): Promise<SubsonicDirectoryEntry[]> {
|
||||
type IndexArtist = { id: string; name: string; coverArt?: string };
|
||||
type IndexEntry = { name: string; artist?: IndexArtist | IndexArtist[] };
|
||||
const data = await api<{ indexes: { index?: IndexEntry | IndexEntry[] } }>(
|
||||
'getIndexes.view',
|
||||
{ musicFolderId },
|
||||
);
|
||||
const raw = data.indexes?.index;
|
||||
if (!raw) return [];
|
||||
const indices = Array.isArray(raw) ? raw : [raw];
|
||||
const entries: SubsonicDirectoryEntry[] = [];
|
||||
for (const idx of indices) {
|
||||
const artists = idx.artist ? (Array.isArray(idx.artist) ? idx.artist : [idx.artist]) : [];
|
||||
for (const a of artists) {
|
||||
entries.push({ id: a.id, title: a.name, isDir: true, coverArt: a.coverArt });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function getMusicFolders(): Promise<SubsonicMusicFolder[]> {
|
||||
const data = await api<{ musicFolders: { musicFolder: SubsonicMusicFolder | SubsonicMusicFolder[] } }>(
|
||||
'getMusicFolders.view',
|
||||
@@ -270,6 +332,19 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
|
||||
}
|
||||
|
||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
||||
const ratingCache = new Map<string, { value: number | undefined; expiresAt: number }>();
|
||||
|
||||
function getCachedRating(key: string): number | undefined | null {
|
||||
const entry = ratingCache.get(key);
|
||||
if (!entry) return null; // cache miss
|
||||
if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; }
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
function setCachedRating(key: string, value: number | undefined): void {
|
||||
ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
|
||||
function parseEntityUserRating(v: unknown): number | undefined {
|
||||
if (v === null || v === undefined) return undefined;
|
||||
@@ -286,22 +361,30 @@ export async function prefetchArtistUserRatings(
|
||||
const unique = [...new Set(ids.filter(Boolean))];
|
||||
const out = new Map<string, number>();
|
||||
if (!unique.length) return out;
|
||||
const uncached: string[] = [];
|
||||
for (const id of unique) {
|
||||
const cached = getCachedRating(`artist:${id}`);
|
||||
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= unique.length) return;
|
||||
const id = unique[i];
|
||||
if (i >= uncached.length) return;
|
||||
const id = uncached[i];
|
||||
try {
|
||||
const { artist } = await getArtist(id);
|
||||
const r = parseEntityUserRating(artist.userRating);
|
||||
setCachedRating(`artist:${id}`, r);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
const nWorkers = Math.min(concurrency, unique.length);
|
||||
const nWorkers = Math.min(concurrency, uncached.length);
|
||||
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||
return out;
|
||||
}
|
||||
@@ -314,22 +397,30 @@ export async function prefetchAlbumUserRatings(
|
||||
const unique = [...new Set(ids.filter(Boolean))];
|
||||
const out = new Map<string, number>();
|
||||
if (!unique.length) return out;
|
||||
const uncached: string[] = [];
|
||||
for (const id of unique) {
|
||||
const cached = getCachedRating(`album:${id}`);
|
||||
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const i = next++;
|
||||
if (i >= unique.length) return;
|
||||
const id = unique[i];
|
||||
if (i >= uncached.length) return;
|
||||
const id = uncached[i];
|
||||
try {
|
||||
const { album } = await getAlbum(id);
|
||||
const r = parseEntityUserRating(album.userRating);
|
||||
setCachedRating(`album:${id}`, r);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
const nWorkers = Math.min(concurrency, unique.length);
|
||||
const nWorkers = Math.min(concurrency, uncached.length);
|
||||
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -26,8 +26,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string, fallbackToFetch
|
||||
return fallbackToFetch ? (resolved || fetchUrl) : resolved;
|
||||
}
|
||||
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, ...props }: CachedImageProps) {
|
||||
export default function CachedImage({ src, cacheKey, style, onLoad, onError, ...props }: CachedImageProps) {
|
||||
const [inView, setInView] = useState(false);
|
||||
const [fallbackSrc, setFallbackSrc] = useState<string | undefined>(undefined);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,14 +50,34 @@ export default function CachedImage({ src, cacheKey, style, onLoad, ...props }:
|
||||
// URL upgrades within the same image — avoids the end-of-load flash.
|
||||
useEffect(() => {
|
||||
setLoaded(false);
|
||||
setFallbackSrc(undefined);
|
||||
}, [cacheKey]);
|
||||
|
||||
const handleError = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (onError) {
|
||||
// Caller wants custom error handling (e.g. hide the element)
|
||||
onError(e);
|
||||
} else {
|
||||
// Nullify the DOM-level handler first to prevent any infinite loop
|
||||
e.currentTarget.onerror = null;
|
||||
setFallbackSrc('/logo-psysonic.png');
|
||||
}
|
||||
};
|
||||
|
||||
const isFallback = fallbackSrc !== undefined;
|
||||
const finalSrc = fallbackSrc ?? (resolvedSrc || undefined);
|
||||
|
||||
const fallbackStyle: React.CSSProperties = isFallback
|
||||
? { objectFit: 'contain', background: 'var(--bg-card, var(--ctp-surface0, #313244))', padding: '15%' }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={resolvedSrc || undefined}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease' }}
|
||||
src={finalSrc}
|
||||
style={{ ...style, opacity: loaded ? 1 : 0, transition: 'opacity 0.15s ease', ...fallbackStyle }}
|
||||
onLoad={e => { setLoaded(true); onLoad?.(e); }}
|
||||
onError={handleError}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -25,15 +25,14 @@ export default function CustomSelect({ value, options, onChange, className = '',
|
||||
|
||||
const selected = options.find(o => o.value === value);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !triggerRef.current) return;
|
||||
const updateDropStyle = () => {
|
||||
if (!triggerRef.current) return;
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const MARGIN = 6;
|
||||
const maxH = 240;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - MARGIN;
|
||||
const spaceAbove = rect.top - MARGIN;
|
||||
const useAbove = spaceBelow < 80 && spaceAbove > spaceBelow;
|
||||
|
||||
setDropStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
@@ -44,6 +43,17 @@ export default function CustomSelect({ value, options, onChange, className = '',
|
||||
maxHeight: Math.min(maxH, useAbove ? spaceAbove : spaceBelow),
|
||||
zIndex: 99998,
|
||||
});
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
updateDropStyle();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener('scroll', updateDropStyle, true);
|
||||
return () => window.removeEventListener('scroll', updateDropStyle, true);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -245,6 +245,10 @@ interface FullscreenPlayerProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Module-level cache: artKey → accent color string.
|
||||
// Survives track changes so same-album songs reuse the extracted color instantly.
|
||||
const coverAccentCache = new Map<string, string>();
|
||||
|
||||
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
@@ -291,18 +295,35 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
// Reset to null on track change so the previous color doesn't linger while
|
||||
// the new one is being extracted.
|
||||
const [dynamicAccent, setDynamicAccent] = useState<string | null>(null);
|
||||
|
||||
// On cover change: hit cache for instant result, or fetch → extract → cache.
|
||||
// Cache hit avoids re-fetching for same-album tracks. Reset only when uncached.
|
||||
useEffect(() => {
|
||||
setDynamicAccent(null);
|
||||
if (!artUrl || !artKey) return;
|
||||
if (!artKey || !artUrl) { setDynamicAccent(null); return; }
|
||||
const cached = coverAccentCache.get(artKey);
|
||||
if (cached) { setDynamicAccent(cached); return; }
|
||||
// No cache hit — keep the previous color visible until extraction completes.
|
||||
let cancelled = false;
|
||||
getCachedUrl(artUrl, artKey).then(blobUrl => {
|
||||
if (cancelled || !blobUrl) return;
|
||||
extractCoverColors(blobUrl).then(colors => {
|
||||
if (!cancelled && colors.accent) setDynamicAccent(colors.accent);
|
||||
});
|
||||
});
|
||||
let blobUrl = '';
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await fetch(artUrl);
|
||||
if (cancelled) return;
|
||||
const blob = await resp.blob();
|
||||
if (cancelled) return;
|
||||
blobUrl = URL.createObjectURL(blob);
|
||||
const colors = await extractCoverColors(blobUrl);
|
||||
if (cancelled) return;
|
||||
if (colors.accent) {
|
||||
coverAccentCache.set(artKey, colors.accent);
|
||||
setDynamicAccent(colors.accent);
|
||||
}
|
||||
} catch { /* ignore */ } finally {
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [artKey]); // artKey is stable per track — artUrl would also work
|
||||
}, [artKey]);
|
||||
|
||||
// Artist image → portrait on right. Falls back to cover art.
|
||||
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
|
||||
|
||||
@@ -36,16 +36,14 @@ export default function NowPlayingDropdown() {
|
||||
});
|
||||
};
|
||||
|
||||
// Poll in background so the badge stays current without opening the dropdown
|
||||
// Poll only while the dropdown is open AND the page is visible.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
fetchNowPlaying();
|
||||
const id = setInterval(fetchNowPlaying, 10000);
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') fetchNowPlaying();
|
||||
}, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Refresh immediately when dropdown is opened
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchNowPlaying();
|
||||
}, [isOpen]);
|
||||
|
||||
// Click outside to close
|
||||
|
||||
@@ -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,
|
||||
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X,
|
||||
} from 'lucide-react';
|
||||
import PsysonicLogo from './PsysonicLogo';
|
||||
import PSmallLogo from './PSmallLogo';
|
||||
@@ -28,6 +29,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
|
||||
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
|
||||
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
|
||||
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
|
||||
folderBrowser: { icon: FolderOpen, labelKey: 'sidebar.folderBrowser', to: '/folders', section: 'library' },
|
||||
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
|
||||
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
|
||||
};
|
||||
@@ -43,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 ?? '');
|
||||
@@ -287,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>
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ThemeDef {
|
||||
accent: string;
|
||||
}
|
||||
|
||||
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
|
||||
{
|
||||
group: 'Games',
|
||||
themes: [
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -152,6 +152,18 @@ function drawWaveform(
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Fade both edges to transparent using destination-in gradient mask
|
||||
const fadeW = Math.min(22, w * 0.07);
|
||||
const mask = ctx.createLinearGradient(0, 0, w, 0);
|
||||
mask.addColorStop(0, 'transparent');
|
||||
mask.addColorStop(fadeW / w, 'black');
|
||||
mask.addColorStop(1 - fadeW / w, 'black');
|
||||
mask.addColorStop(1, 'transparent');
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.fillStyle = mask;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
function drawLineDot(canvas: HTMLCanvasElement, progress: number, buffered: number) {
|
||||
@@ -381,16 +393,6 @@ function drawPulseWave(
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
// Played flat line
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.fillRect(0, cy - 1, px, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Animated pulse centered at playhead
|
||||
const pulseR = Math.min(38, w * 0.13);
|
||||
const amp = Math.min(h * 0.42, 5.5);
|
||||
@@ -398,6 +400,16 @@ function drawPulseWave(
|
||||
const startX = Math.max(0, px - pulseR);
|
||||
const endX = Math.min(w, px + pulseR);
|
||||
|
||||
// Flat played line up to where the wave envelope starts
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 3;
|
||||
ctx.fillRect(0, cy - 1, startX, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = played;
|
||||
ctx.lineWidth = 1.5;
|
||||
@@ -604,80 +616,76 @@ function drawLiquidFill(
|
||||
function drawRetroTape(
|
||||
canvas: HTMLCanvasElement,
|
||||
progress: number,
|
||||
_buffered: number,
|
||||
buffered: number,
|
||||
animState: AnimState,
|
||||
) {
|
||||
const r = setupCanvas(canvas);
|
||||
if (!r) return;
|
||||
const { ctx, w, h } = r;
|
||||
const { played, unplayed } = getColors();
|
||||
const { played, buffered: buffCol, unplayed } = getColors();
|
||||
const cy = h / 2;
|
||||
|
||||
animState.angle += 0.042;
|
||||
animState.angle += 0.055;
|
||||
|
||||
const maxR = Math.floor(h / 2) - 1;
|
||||
const minR = Math.max(2, maxR * 0.28);
|
||||
// supply reel (left): shrinks as progress increases
|
||||
const rL = minR + (maxR - minR) * (1 - progress);
|
||||
// takeup reel (right): grows as progress increases
|
||||
const rR = minR + (maxR - minR) * progress;
|
||||
const lx = maxR + 1;
|
||||
const rx = w - maxR - 1;
|
||||
const reelR = Math.min(h / 2 - 0.5, 9);
|
||||
// Map progress to a center x that keeps the reel fully within the canvas
|
||||
const px = reelR + (w - 2 * reelR) * progress;
|
||||
|
||||
// Tape between reels
|
||||
const tapeLeft = lx + rL + 0.5;
|
||||
const tapeRight = rx - rR - 0.5;
|
||||
if (tapeRight > tapeLeft) {
|
||||
ctx.globalAlpha = 0.45;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(tapeLeft, cy - 1.5, tapeRight - tapeLeft, 3);
|
||||
// Background track
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = unplayed;
|
||||
ctx.fillRect(0, cy - 1, w, 2);
|
||||
|
||||
if (buffered > 0) {
|
||||
ctx.globalAlpha = 0.5;
|
||||
ctx.fillStyle = buffCol;
|
||||
ctx.fillRect(0, cy - 1, buffered * w, 2);
|
||||
}
|
||||
|
||||
const drawReel = (
|
||||
cx: number,
|
||||
radius: number,
|
||||
angle: number,
|
||||
isRight: boolean,
|
||||
) => {
|
||||
const color = isRight ? played : unplayed;
|
||||
const alpha = isRight ? (progress > 0 ? 1 : 0.35) : (progress < 1 ? 0.55 : 0.35);
|
||||
const glowing = isRight && progress > 0;
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
if (glowing) { ctx.shadowColor = played; ctx.shadowBlur = 6; }
|
||||
|
||||
// Outer ring
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
// Played portion — up to the left edge of the reel
|
||||
if (progress > 0) {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = played;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillRect(0, cy - 1, px - reelR, 2);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Hub
|
||||
const hubR = Math.max(1.5, radius * 0.3);
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, hubR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Spinning reel at playhead
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.strokeStyle = played;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.shadowColor = played;
|
||||
ctx.shadowBlur = 7;
|
||||
|
||||
// Spokes (only if reel is large enough)
|
||||
if (radius > hubR + 2.5) {
|
||||
ctx.lineWidth = 0.9;
|
||||
ctx.strokeStyle = color;
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const a = angle + (s * Math.PI * 2) / 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + Math.cos(a) * (hubR + 0.5), cy + Math.sin(a) * (hubR + 0.5));
|
||||
ctx.lineTo(cx + Math.cos(a) * (radius - 0.5), cy + Math.sin(a) * (radius - 0.5));
|
||||
ctx.stroke();
|
||||
}
|
||||
// Outer ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, reelR, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Hub
|
||||
const hubR = Math.max(1.5, reelR * 0.28);
|
||||
ctx.fillStyle = played;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, cy, hubR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Spokes
|
||||
if (reelR > hubR + 2) {
|
||||
ctx.lineWidth = 0.9;
|
||||
ctx.strokeStyle = played;
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const a = animState.angle + (s * Math.PI * 2) / 3;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + Math.cos(a) * (hubR + 0.5), cy + Math.sin(a) * (hubR + 0.5));
|
||||
ctx.lineTo(px + Math.cos(a) * (reelR - 0.5), cy + Math.sin(a) * (reelR - 0.5));
|
||||
ctx.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
drawReel(lx, rL, -animState.angle, false);
|
||||
drawReel(rx, rR, animState.angle, true);
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export function useConnectionStatus() {
|
||||
|
||||
useEffect(() => {
|
||||
check();
|
||||
intervalRef.current = setInterval(check, 30_000);
|
||||
intervalRef.current = setInterval(check, 120_000);
|
||||
|
||||
const handleOnline = () => check();
|
||||
const handleOffline = () => setStatus('disconnected');
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useThemeStore, getScheduledTheme } from '../store/themeStore';
|
||||
|
||||
export function useThemeScheduler(): string {
|
||||
const state = useThemeStore();
|
||||
const [effectiveTheme, setEffectiveTheme] = useState(() => getScheduledTheme(state));
|
||||
|
||||
useEffect(() => {
|
||||
setEffectiveTheme(getScheduledTheme(useThemeStore.getState()));
|
||||
if (!state.enableThemeScheduler) return;
|
||||
const id = setInterval(() => {
|
||||
setEffectiveTheme(getScheduledTheme(useThemeStore.getState()));
|
||||
}, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [state.enableThemeScheduler, state.theme, state.themeDay, state.themeNight, state.timeDayStart, state.timeNightStart]);
|
||||
|
||||
return effectiveTheme;
|
||||
}
|
||||
+23
-6
@@ -16,11 +16,13 @@ export const deTranslation = {
|
||||
expand: 'Sidebar einblenden',
|
||||
collapse: 'Sidebar ausblenden',
|
||||
downloadingTracks: '{{n}} Tracks werden gecacht…',
|
||||
cancelDownload: 'Download abbrechen',
|
||||
offlineLibrary: 'Offline-Bibliothek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Meistgehört',
|
||||
radio: 'Internetradio',
|
||||
folderBrowser: 'Ordner-Browser',
|
||||
libraryScope: 'Bibliotheksumfang',
|
||||
allLibraries: 'Alle Bibliotheken',
|
||||
},
|
||||
@@ -520,13 +522,13 @@ export const deTranslation = {
|
||||
tabSystem: 'System',
|
||||
tabGeneral: 'Allgemein',
|
||||
ratingsSectionTitle: 'Bewertungen',
|
||||
ratingsSkipStarTitle: 'Überspringen für 1 Stern',
|
||||
ratingsSkipStarTitle: 'Skip → 1 Stern',
|
||||
ratingsSkipStarDesc:
|
||||
'Bei N Überspringen hintereinander: Titel auf 1 Stern setzen. Nur für zuvor unbewertete Titel.',
|
||||
ratingsSkipStarThresholdLabel: 'Überspringer bis 1★',
|
||||
ratingsMixFilterTitle: 'Filter nach Bewertung',
|
||||
'Wird ein Titel mehrmals hintereinander übersprungen, bekommt er automatisch 1★. Nur für noch nicht bewertete Titel.',
|
||||
ratingsSkipStarThresholdLabel: 'Skips',
|
||||
ratingsMixFilterTitle: 'Nach Bewertung filtern',
|
||||
ratingsMixFilterDesc:
|
||||
'Inhalte mit niedriger Bewertung in {{mix}} und {{albums}} filtern. Erneut auf den gewählten Stern klicken, um die Schwelle zu deaktivieren.',
|
||||
'Gering bewertete Titel in {{mix}} und {{albums}} ausblenden. Nochmals auf den gewählten Stern klicken, um den Filter zu deaktivieren.',
|
||||
ratingsMixMinSong: 'Titel',
|
||||
ratingsMixMinAlbum: 'Alben',
|
||||
ratingsMixMinArtist: 'Interpreten',
|
||||
@@ -574,6 +576,16 @@ export const deTranslation = {
|
||||
seekbarParticletrail: 'Partikel-Spur',
|
||||
seekbarLiquidfill: 'Flüssigkeit',
|
||||
seekbarRetrotape: 'Retro-Band',
|
||||
themeSchedulerTitle: 'Theme-Zeitplan',
|
||||
themeSchedulerEnable: 'Theme-Zeitplan aktivieren',
|
||||
themeSchedulerEnableSub: 'Wechselt automatisch zwischen zwei Themes basierend auf der Uhrzeit',
|
||||
themeSchedulerDayTheme: 'Tages-Theme',
|
||||
themeSchedulerDayStart: 'Tag beginnt um',
|
||||
themeSchedulerNightTheme: 'Nacht-Theme',
|
||||
themeSchedulerNightStart: 'Nacht beginnt um',
|
||||
themeSchedulerActiveHint: 'Theme-Zeitplan ist aktiv - Themes werden automatisch gewechselt.',
|
||||
uiScaleTitle: 'Interface-Skalierung',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Was ist neu',
|
||||
@@ -812,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',
|
||||
@@ -871,5 +884,9 @@ export const deTranslation = {
|
||||
favorite: 'Zu Favoriten hinzufügen',
|
||||
unfavorite: 'Aus Favoriten entfernen',
|
||||
noFavorites: 'Keine Lieblingssender.',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Leerer Ordner',
|
||||
error: 'Laden fehlgeschlagen',
|
||||
},
|
||||
};
|
||||
|
||||
+20
-3
@@ -17,11 +17,13 @@ export const enTranslation = {
|
||||
collapse: 'Collapse Sidebar',
|
||||
|
||||
downloadingTracks: 'Caching {{n}} tracks…',
|
||||
cancelDownload: 'Cancel download',
|
||||
offlineLibrary: 'Offline Library',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Most Played',
|
||||
radio: 'Internet Radio',
|
||||
folderBrowser: 'Folder Browser',
|
||||
libraryScope: 'Library scope',
|
||||
allLibraries: 'All libraries',
|
||||
},
|
||||
@@ -507,8 +509,8 @@ export const enTranslation = {
|
||||
ratingsSectionTitle: 'Ratings',
|
||||
ratingsSkipStarTitle: 'Skip for 1 star',
|
||||
ratingsSkipStarDesc:
|
||||
'After N skips in a row, set the track to 1★. Only for tracks not rated before.',
|
||||
ratingsSkipStarThresholdLabel: 'Skips before 1★',
|
||||
'After several skips in a row, set the track to 1★. Only for tracks not yet rated.',
|
||||
ratingsSkipStarThresholdLabel: 'Skips',
|
||||
ratingsMixFilterTitle: 'Filter by rating',
|
||||
ratingsMixFilterDesc:
|
||||
'Filter low-rated items in {{mix}} and {{albums}}. Click the selected star again to turn off the threshold.',
|
||||
@@ -575,6 +577,16 @@ export const enTranslation = {
|
||||
seekbarParticletrail: 'Particle Trail',
|
||||
seekbarLiquidfill: 'Liquid Fill',
|
||||
seekbarRetrotape: 'Retro Tape',
|
||||
themeSchedulerTitle: 'Auto-Switch Theme',
|
||||
themeSchedulerEnable: 'Enable Theme Scheduler',
|
||||
themeSchedulerEnableSub: 'Automatically switch between two themes based on the time of day',
|
||||
themeSchedulerDayTheme: 'Day Theme',
|
||||
themeSchedulerDayStart: 'Day Starts At',
|
||||
themeSchedulerNightTheme: 'Night Theme',
|
||||
themeSchedulerNightStart: 'Night Starts At',
|
||||
themeSchedulerActiveHint: 'Theme Scheduler is active - theme changes are managed automatically.',
|
||||
uiScaleTitle: 'Interface Scale',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: "What's New",
|
||||
@@ -813,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',
|
||||
@@ -872,5 +885,9 @@ export const enTranslation = {
|
||||
favorite: 'Add to favorites',
|
||||
unfavorite: 'Remove from favorites',
|
||||
noFavorites: 'No favorite stations.',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Empty folder',
|
||||
error: 'Failed to load',
|
||||
},
|
||||
};
|
||||
|
||||
+21
-4
@@ -16,11 +16,13 @@ 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',
|
||||
mostPlayed: 'Les plus joués',
|
||||
radio: 'Radio Internet',
|
||||
folderBrowser: 'Explorateur de dossiers',
|
||||
libraryScope: 'Portée de la bibliothèque',
|
||||
allLibraries: 'Toutes les bibliothèques',
|
||||
},
|
||||
@@ -520,11 +522,11 @@ export const frTranslation = {
|
||||
ratingsSectionTitle: 'Notes',
|
||||
ratingsSkipStarTitle: 'Passer pour 1 étoile',
|
||||
ratingsSkipStarDesc:
|
||||
'Après N sauts d’affilée : mettre le morceau à 1 étoile. Uniquement s’il n’était pas encore noté.',
|
||||
ratingsSkipStarThresholdLabel: 'Sauts avant 1★',
|
||||
"Après plusieurs sauts d’affilée, le morceau passe à 1 étoile. Uniquement s’il n’était pas encore noté.",
|
||||
ratingsSkipStarThresholdLabel: 'Sauts',
|
||||
ratingsMixFilterTitle: 'Filtrage par note',
|
||||
ratingsMixFilterDesc:
|
||||
'Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.',
|
||||
"Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.",
|
||||
ratingsMixMinSong: 'Morceaux',
|
||||
ratingsMixMinAlbum: 'Albums',
|
||||
ratingsMixMinArtist: 'Artistes',
|
||||
@@ -572,6 +574,16 @@ export const frTranslation = {
|
||||
seekbarParticletrail: 'Traînée de particules',
|
||||
seekbarLiquidfill: 'Tube liquide',
|
||||
seekbarRetrotape: 'Bande rétro',
|
||||
themeSchedulerTitle: 'Planificateur de thème',
|
||||
themeSchedulerEnable: 'Activer le planificateur de thème',
|
||||
themeSchedulerEnableSub: 'Bascule automatiquement entre deux thèmes selon l\'heure',
|
||||
themeSchedulerDayTheme: 'Thème de jour',
|
||||
themeSchedulerDayStart: 'Début du jour',
|
||||
themeSchedulerNightTheme: 'Thème de nuit',
|
||||
themeSchedulerNightStart: 'Début de la nuit',
|
||||
themeSchedulerActiveHint: 'Le planificateur de thème est actif - les thèmes changent automatiquement.',
|
||||
uiScaleTitle: "Mise à l'échelle de l'interface",
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Quoi de neuf',
|
||||
@@ -810,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',
|
||||
@@ -866,5 +879,9 @@ export const frTranslation = {
|
||||
favorite: 'Ajouter aux favoris',
|
||||
unfavorite: 'Retirer des favoris',
|
||||
noFavorites: 'Aucune station favorite.',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Dossier vide',
|
||||
error: 'Échec du chargement',
|
||||
},
|
||||
};
|
||||
|
||||
+20
-3
@@ -16,11 +16,13 @@ export const nbTranslation = {
|
||||
expand: 'Utvid sidefelt',
|
||||
collapse: 'Skjul sidefelt',
|
||||
downloadingTracks: 'Bufre {{n}} spor…',
|
||||
cancelDownload: 'Avbryt nedlasting',
|
||||
offlineLibrary: 'Frakoblet bibliotek',
|
||||
genres: 'Sjangere',
|
||||
playlists: 'Spillelister',
|
||||
mostPlayed: 'Mest spilt',
|
||||
radio: 'Internettradio',
|
||||
folderBrowser: 'Mappeleser',
|
||||
libraryScope: 'Biblioteksomfang',
|
||||
allLibraries: 'Alle biblioteker',
|
||||
},
|
||||
@@ -503,8 +505,8 @@ export const nbTranslation = {
|
||||
ratingsSectionTitle: 'Vurderinger',
|
||||
ratingsSkipStarTitle: 'Hopp for 1 stjerne',
|
||||
ratingsSkipStarDesc:
|
||||
'Etter N hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.',
|
||||
ratingsSkipStarThresholdLabel: 'Hopp før 1★',
|
||||
'Etter flere hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.',
|
||||
ratingsSkipStarThresholdLabel: 'Hopp',
|
||||
ratingsMixFilterTitle: 'Filtrering etter vurdering',
|
||||
ratingsMixFilterDesc:
|
||||
'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.',
|
||||
@@ -571,6 +573,16 @@ export const nbTranslation = {
|
||||
seekbarParticletrail: 'Partikkelspor',
|
||||
seekbarLiquidfill: 'Væskerør',
|
||||
seekbarRetrotape: 'Retrotape',
|
||||
themeSchedulerTitle: 'Tidsplanlagt tema',
|
||||
themeSchedulerEnable: 'Aktiver temaplanlegger',
|
||||
themeSchedulerEnableSub: 'Bytter automatisk mellom to temaer basert på tidspunkt',
|
||||
themeSchedulerDayTheme: 'Dagtema',
|
||||
themeSchedulerDayStart: 'Dag starter kl.',
|
||||
themeSchedulerNightTheme: 'Natttema',
|
||||
themeSchedulerNightStart: 'Natt starter kl.',
|
||||
themeSchedulerActiveHint: 'Temaplanlegger er aktiv - temaer byttes automatisk.',
|
||||
uiScaleTitle: 'Grensesnittskala',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: "Nyheter",
|
||||
@@ -809,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',
|
||||
@@ -865,5 +878,9 @@ export const nbTranslation = {
|
||||
favorite: 'Legg til i favoritter',
|
||||
unfavorite: 'Fjern fra favoritter',
|
||||
noFavorites: 'Ingen favorittstasjoner.',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Tom mappe',
|
||||
error: 'Kunne ikke laste',
|
||||
},
|
||||
};
|
||||
|
||||
+20
-3
@@ -16,11 +16,13 @@ export const nlTranslation = {
|
||||
expand: 'Zijbalk uitklappen',
|
||||
collapse: 'Zijbalk inklappen',
|
||||
downloadingTracks: '{{n}} nummers worden gecached…',
|
||||
cancelDownload: 'Download annuleren',
|
||||
offlineLibrary: 'Offline bibliotheek',
|
||||
genres: 'Genres',
|
||||
playlists: 'Playlists',
|
||||
mostPlayed: 'Meest gespeeld',
|
||||
radio: 'Internetradio',
|
||||
folderBrowser: 'Mappenverkenner',
|
||||
libraryScope: 'Bibliotheekbereik',
|
||||
allLibraries: 'Alle bibliotheken',
|
||||
},
|
||||
@@ -520,8 +522,8 @@ export const nlTranslation = {
|
||||
ratingsSectionTitle: 'Beoordelingen',
|
||||
ratingsSkipStarTitle: 'Overslaan voor 1 ster',
|
||||
ratingsSkipStarDesc:
|
||||
'Na N overslagen op rij: nummer op 1 ster zetten. Alleen voor nummers die nog niet beoordeeld waren.',
|
||||
ratingsSkipStarThresholdLabel: 'Overslagen voor 1★',
|
||||
'Na meerdere overslagen op rij: nummer op 1 ster zetten. Alleen voor nummers die nog niet beoordeeld waren.',
|
||||
ratingsSkipStarThresholdLabel: 'Overslagen',
|
||||
ratingsMixFilterTitle: 'Filter op beoordeling',
|
||||
ratingsMixFilterDesc:
|
||||
'Content met lage beoordeling filteren in {{mix}} en {{albums}}. Klik opnieuw op de gekozen ster om de drempel uit te zetten.',
|
||||
@@ -572,6 +574,16 @@ export const nlTranslation = {
|
||||
seekbarParticletrail: 'Deeltjesspoor',
|
||||
seekbarLiquidfill: 'Vloeistofbuis',
|
||||
seekbarRetrotape: 'Retrotape',
|
||||
themeSchedulerTitle: 'Thema-planner',
|
||||
themeSchedulerEnable: 'Thema-planner inschakelen',
|
||||
themeSchedulerEnableSub: 'Schakelt automatisch tussen twee thema\'s op basis van de tijd',
|
||||
themeSchedulerDayTheme: 'Dagthema',
|
||||
themeSchedulerDayStart: 'Dag begint om',
|
||||
themeSchedulerNightTheme: 'Nachtthema',
|
||||
themeSchedulerNightStart: 'Nacht begint om',
|
||||
themeSchedulerActiveHint: "Thema-planner is actief - thema's worden automatisch gewisseld.",
|
||||
uiScaleTitle: 'Interface schaal',
|
||||
uiScaleLabel: 'Zoom',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Wat is nieuw',
|
||||
@@ -810,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é',
|
||||
@@ -866,5 +879,9 @@ export const nlTranslation = {
|
||||
favorite: 'Toevoegen aan favorieten',
|
||||
unfavorite: 'Verwijderen uit favorieten',
|
||||
noFavorites: 'Geen favoriete stations.',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Lege map',
|
||||
error: 'Laden mislukt',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,11 +17,13 @@ export const ruTranslation = {
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
downloadingTracks: 'Кэширование {{n}} треков…',
|
||||
cancelDownload: 'Отменить загрузку',
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
playlists: 'Плейлисты',
|
||||
mostPlayed: 'Часто слушаемое',
|
||||
radio: 'Онлайн-радио',
|
||||
folderBrowser: 'Браузер папок',
|
||||
libraryScope: 'Область медиатеки',
|
||||
allLibraries: 'Все библиотеки',
|
||||
},
|
||||
@@ -598,6 +600,16 @@ export const ruTranslation = {
|
||||
seekbarParticletrail: 'Частицы',
|
||||
seekbarLiquidfill: 'Жидкость',
|
||||
seekbarRetrotape: 'Ретро-лента',
|
||||
themeSchedulerTitle: 'Расписание тем',
|
||||
themeSchedulerEnable: 'Включить расписание тем',
|
||||
themeSchedulerEnableSub: 'Автоматически переключается между двумя темами в зависимости от времени суток',
|
||||
themeSchedulerDayTheme: 'Дневная тема',
|
||||
themeSchedulerDayStart: 'День начинается в',
|
||||
themeSchedulerNightTheme: 'Ночная тема',
|
||||
themeSchedulerNightStart: 'Ночь начинается в',
|
||||
themeSchedulerActiveHint: 'Расписание тем активно - темы переключаются автоматически.',
|
||||
uiScaleTitle: 'Масштаб интерфейса',
|
||||
uiScaleLabel: 'Масштаб',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: 'Что нового',
|
||||
@@ -867,6 +879,7 @@ export const ruTranslation = {
|
||||
addSong: 'В плейлист',
|
||||
cacheOffline: 'Сохранить плейлист офлайн',
|
||||
offlineCached: 'Плейлист сохранён',
|
||||
removeOffline: 'Удалить из офлайн-кэша',
|
||||
offlineDownloading: 'Кэширование… ({{done}} из {{total}} альбомов)',
|
||||
publicLabel: 'Публичный',
|
||||
privateLabel: 'Личный',
|
||||
@@ -924,4 +937,8 @@ export const ruTranslation = {
|
||||
unfavorite: 'Убрать из избранного',
|
||||
noFavorites: 'Избранных станций нет.',
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: 'Папка пуста',
|
||||
error: 'Ошибка загрузки',
|
||||
},
|
||||
};
|
||||
|
||||
+20
-3
@@ -16,11 +16,13 @@ export const zhTranslation = {
|
||||
expand: '展开侧边栏',
|
||||
collapse: '收起侧边栏',
|
||||
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
|
||||
cancelDownload: '取消下载',
|
||||
offlineLibrary: '离线音乐库',
|
||||
genres: '流派',
|
||||
playlists: '播放列表',
|
||||
mostPlayed: '最常播放',
|
||||
radio: '网络电台',
|
||||
folderBrowser: '文件夹浏览器',
|
||||
libraryScope: '资料库范围',
|
||||
allLibraries: '所有资料库',
|
||||
},
|
||||
@@ -500,8 +502,8 @@ export const zhTranslation = {
|
||||
ratingsSectionTitle: '评分',
|
||||
ratingsSkipStarTitle: '跳过以评 1 星',
|
||||
ratingsSkipStarDesc:
|
||||
'连续跳过 N 次后将曲目设为 1 星。仅适用于此前未评分的曲目。',
|
||||
ratingsSkipStarThresholdLabel: '跳过次数(至 1★)',
|
||||
'连续多次跳过后将曲目设为 1 星。仅适用于此前未评分的曲目。',
|
||||
ratingsSkipStarThresholdLabel: '跳过次数',
|
||||
ratingsMixFilterTitle: '按评分筛选',
|
||||
ratingsMixFilterDesc:
|
||||
'在{{mix}}与{{albums}}中筛选低评分内容。再次点击所选星标可关闭阈值。',
|
||||
@@ -568,6 +570,16 @@ export const zhTranslation = {
|
||||
seekbarParticletrail: '粒子轨迹',
|
||||
seekbarLiquidfill: '液体填充',
|
||||
seekbarRetrotape: '复古磁带',
|
||||
themeSchedulerTitle: '主题定时切换',
|
||||
themeSchedulerEnable: '启用主题定时器',
|
||||
themeSchedulerEnableSub: '根据一天中的时间自动在两个主题之间切换',
|
||||
themeSchedulerDayTheme: '白天主题',
|
||||
themeSchedulerDayStart: '白天开始时间',
|
||||
themeSchedulerNightTheme: '夜晚主题',
|
||||
themeSchedulerNightStart: '夜晚开始时间',
|
||||
themeSchedulerActiveHint: '主题定时器已启用 - 主题将自动切换。',
|
||||
uiScaleTitle: '界面缩放',
|
||||
uiScaleLabel: '缩放',
|
||||
},
|
||||
changelog: {
|
||||
modalTitle: '新功能',
|
||||
@@ -806,6 +818,7 @@ export const zhTranslation = {
|
||||
addSong: '添加到播放列表',
|
||||
cacheOffline: '离线缓存播放列表',
|
||||
offlineCached: '播放列表已缓存',
|
||||
removeOffline: '从离线缓存中移除',
|
||||
offlineDownloading: '正在缓存… ({{done}}/{{total}} 张专辑)',
|
||||
publicLabel: '公开',
|
||||
privateLabel: '私有',
|
||||
@@ -862,5 +875,9 @@ export const zhTranslation = {
|
||||
favorite: '添加到收藏',
|
||||
unfavorite: '从收藏移除',
|
||||
noFavorites: '没有收藏的电台。',
|
||||
}
|
||||
},
|
||||
folderBrowser: {
|
||||
empty: '空文件夹',
|
||||
error: '加载失败',
|
||||
},
|
||||
};
|
||||
|
||||
+49
-61
@@ -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);
|
||||
const filename = `${sanitizeFilename(name)}.zip`;
|
||||
const destPath = await join(folder, filename);
|
||||
const url = buildDownloadUrl(albumId);
|
||||
const downloadId = crypto.randomUUID();
|
||||
|
||||
const { start, complete, fail } = useZipDownloadStore.getState();
|
||||
start(downloadId, filename);
|
||||
try {
|
||||
const url = buildDownloadUrl(albumId);
|
||||
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(name)}.zip`);
|
||||
await writeFile(path, new Uint8Array(buffer));
|
||||
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
@@ -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');
|
||||
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 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++;
|
||||
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 () => {
|
||||
|
||||
+20
-10
@@ -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);
|
||||
@@ -80,24 +82,32 @@ export default function ArtistDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setInfo(null);
|
||||
setTopSongs([]);
|
||||
setFeaturedAlbums([]);
|
||||
getArtist(id).then(artistData => {
|
||||
if (cancelled) return;
|
||||
setArtist(artistData.artist);
|
||||
setAlbums(artistData.albums);
|
||||
setIsStarred(!!artistData.artist.starred);
|
||||
return Promise.all([
|
||||
getArtistInfo(id).catch(() => null),
|
||||
getTopSongs(artistData.artist.name).catch(() => []),
|
||||
]);
|
||||
}).then(([artistInfo, songsData]) => {
|
||||
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
|
||||
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
|
||||
// Render the page immediately from local data
|
||||
setLoading(false);
|
||||
|
||||
// Fetch artist info (may trigger slow external lookup on the server)
|
||||
// and top songs in the background — do not block rendering
|
||||
getArtistInfo(id).then(artistInfo => {
|
||||
if (!cancelled) setInfo(artistInfo ?? null);
|
||||
}).catch(() => {});
|
||||
|
||||
getTopSongs(artistData.artist.name).then(songsData => {
|
||||
if (!cancelled) setTopSongs(songsData ?? []);
|
||||
}).catch(() => {});
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
setLoading(false);
|
||||
if (!cancelled) { console.error(err); setLoading(false); }
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { getMusicFolders, getMusicDirectory, getMusicIndexes, SubsonicDirectoryEntry } from '../api/subsonic';
|
||||
import { usePlayerStore, Track } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder, FolderOpen, Music, ChevronRight } from 'lucide-react';
|
||||
|
||||
// ── types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Column = {
|
||||
id: string;
|
||||
name: string;
|
||||
items: SubsonicDirectoryEntry[];
|
||||
selectedId: string | null;
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
};
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function entryToTrack(e: SubsonicDirectoryEntry): Track {
|
||||
return {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
artist: e.artist ?? '',
|
||||
album: e.album ?? '',
|
||||
albumId: e.albumId ?? '',
|
||||
artistId: e.artistId,
|
||||
coverArt: e.coverArt,
|
||||
duration: e.duration ?? 0,
|
||||
track: e.track,
|
||||
year: e.year,
|
||||
bitRate: e.bitRate,
|
||||
suffix: e.suffix,
|
||||
genre: e.genre,
|
||||
starred: e.starred,
|
||||
userRating: e.userRating,
|
||||
};
|
||||
}
|
||||
|
||||
// ── component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function FolderBrowser() {
|
||||
const { t } = useTranslation();
|
||||
const [columns, setColumns] = useState<Column[]>([]);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
|
||||
// ── root: load music folders on mount ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const placeholder: Column = { id: 'root', name: '', items: [], selectedId: null, loading: true, error: false };
|
||||
setColumns([placeholder]);
|
||||
getMusicFolders()
|
||||
.then(folders => {
|
||||
const items: SubsonicDirectoryEntry[] = folders.map(f => ({
|
||||
id: f.id,
|
||||
title: f.name,
|
||||
isDir: true,
|
||||
}));
|
||||
setColumns([{ ...placeholder, items, loading: false }]);
|
||||
})
|
||||
.catch(() => {
|
||||
setColumns([{ ...placeholder, items: [], loading: false, error: true }]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── auto-scroll to newly added column ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = wrapperRef.current;
|
||||
if (!el) return;
|
||||
requestAnimationFrame(() => { el.scrollLeft = el.scrollWidth; });
|
||||
}, [columns.length]);
|
||||
|
||||
// ── click a directory ──────────────────────────────────────────────────────
|
||||
const handleDirClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||
// Mark selected + truncate columns after this one + add loading column
|
||||
setColumns(prev => [
|
||||
...prev.slice(0, colIndex + 1).map((c, i) =>
|
||||
i === colIndex ? { ...c, selectedId: item.id } : c,
|
||||
),
|
||||
{ id: item.id, name: item.title, items: [], selectedId: null, loading: true, error: false },
|
||||
]);
|
||||
|
||||
// Column 0 holds music folder roots — their IDs are only valid for
|
||||
// getIndexes.view (musicFolderId), not getMusicDirectory.view
|
||||
const fetchItems = colIndex === 0
|
||||
? getMusicIndexes(item.id)
|
||||
: getMusicDirectory(item.id).then(d => d.child);
|
||||
|
||||
fetchItems
|
||||
.then(items => {
|
||||
setColumns(prev => {
|
||||
const idx = prev.findIndex(c => c.id === item.id && c.loading);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], items, loading: false };
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setColumns(prev => {
|
||||
const idx = prev.findIndex(c => c.id === item.id && c.loading);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], loading: false, error: true };
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── click a file (track) ───────────────────────────────────────────────────
|
||||
const handleFileClick = useCallback((colIndex: number, item: SubsonicDirectoryEntry) => {
|
||||
setColumns(prev => prev.map((c, i) =>
|
||||
i === colIndex ? { ...c, selectedId: item.id } : c,
|
||||
));
|
||||
// Build queue from all tracks in this column
|
||||
const col = columns[colIndex];
|
||||
const queue = col.items.filter(it => !it.isDir).map(entryToTrack);
|
||||
playTrack(entryToTrack(item), queue.length > 0 ? queue : [entryToTrack(item)]);
|
||||
}, [columns, playTrack]);
|
||||
|
||||
// ── render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="folder-browser">
|
||||
<h1 className="page-title folder-browser-title">{t('sidebar.folderBrowser')}</h1>
|
||||
<div className="folder-browser-columns" ref={wrapperRef}>
|
||||
{columns.map((col, colIndex) => (
|
||||
<div key={`${col.id}-${colIndex}`} className="folder-col">
|
||||
{col.loading ? (
|
||||
<div className="folder-col-status">
|
||||
<div className="spinner" style={{ width: 20, height: 20 }} />
|
||||
</div>
|
||||
) : col.error ? (
|
||||
<div className="folder-col-status folder-col-error">
|
||||
{t('folderBrowser.error')}
|
||||
</div>
|
||||
) : col.items.length === 0 ? (
|
||||
<div className="folder-col-status">{t('folderBrowser.empty')}</div>
|
||||
) : (
|
||||
col.items.map(item => {
|
||||
const isSelected = col.selectedId === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`folder-col-row${isSelected ? ' selected' : ''}`}
|
||||
onClick={() =>
|
||||
item.isDir
|
||||
? handleDirClick(colIndex, item)
|
||||
: handleFileClick(colIndex, item)
|
||||
}
|
||||
>
|
||||
<span className="folder-col-icon">
|
||||
{item.isDir
|
||||
? isSelected
|
||||
? <FolderOpen size={14} />
|
||||
: <Folder size={14} />
|
||||
: <Music size={14} />}
|
||||
</span>
|
||||
<span className="folder-col-name">{item.title}</span>
|
||||
{item.isDir && (
|
||||
<ChevronRight size={12} className="folder-col-chevron" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -111,7 +111,7 @@ export default function Home() {
|
||||
{isVisible('recent') && (
|
||||
<AlbumRow
|
||||
title={t('home.recent')}
|
||||
titleLink="/albums"
|
||||
titleLink="/new-releases"
|
||||
albums={recent}
|
||||
onLoadMore={() => loadMore('newest', recent, setRecent)}
|
||||
moreText={t('home.loadMore')}
|
||||
|
||||
+13
-10
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
const filename = `${sanitizeFilename(playlist.name)}.zip`;
|
||||
const destPath = await join(folder, filename);
|
||||
const url = buildDownloadUrl(id);
|
||||
const downloadId = crypto.randomUUID();
|
||||
|
||||
const { start, complete, fail } = useZipDownloadStore.getState();
|
||||
start(downloadId, filename);
|
||||
setZipDownloadId(downloadId);
|
||||
try {
|
||||
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));
|
||||
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 (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={isDownloading}
|
||||
onClick={() => { 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')}
|
||||
>
|
||||
{isDownloading
|
||||
? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
|
||||
: isCached ? <Check size={16} /> : <HardDriveDownload size={16} />}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{songs.length > 0 && id && (
|
||||
<button
|
||||
className={`btn btn-ghost${isCached ? ' btn-danger' : ''}`}
|
||||
disabled={isDownloading}
|
||||
onClick={() => {
|
||||
if (isCached) {
|
||||
deleteAlbum(id, activeServerId);
|
||||
} else if (playlist) {
|
||||
downloadPlaylist(id, playlist.name, playlist.coverArt, songs, activeServerId);
|
||||
}
|
||||
}}
|
||||
data-tooltip={isDownloading
|
||||
? 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 ? <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
@@ -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 () => {
|
||||
|
||||
+130
-3
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
|
||||
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star
|
||||
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn
|
||||
} from 'lucide-react';
|
||||
import { exportBackup, importBackup } from '../utils/backup';
|
||||
import { showToast } from '../utils/toast';
|
||||
@@ -17,7 +17,7 @@ import { useHotCacheStore } from '../store/hotCacheStore';
|
||||
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
|
||||
import LastfmIcon from '../components/LastfmIcon';
|
||||
import CustomSelect from '../components/CustomSelect';
|
||||
import ThemePicker from '../components/ThemePicker';
|
||||
import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker';
|
||||
import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle } from '../store/authStore';
|
||||
import { SeekbarPreview } from '../components/WaveformSeek';
|
||||
import { IS_LINUX } from '../utils/platform';
|
||||
@@ -202,6 +202,13 @@ export default function Settings() {
|
||||
const clearAllOffline = useOfflineStore(s => s.clearAll);
|
||||
const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk);
|
||||
const hotCacheEntries = useHotCacheStore(s => s.entries);
|
||||
const [isTilingWm, setIsTilingWm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_LINUX) return;
|
||||
invoke<boolean>('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const hotCacheTrackCount = useMemo(() => {
|
||||
if (!serverId) return 0;
|
||||
const prefix = `${serverId}:`;
|
||||
@@ -618,7 +625,7 @@ export default function Settings() {
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{IS_LINUX && (
|
||||
{IS_LINUX && !isTilingWm && (
|
||||
<>
|
||||
<div className="settings-section-divider" />
|
||||
<div className="settings-toggle-row">
|
||||
@@ -1216,10 +1223,130 @@ export default function Settings() {
|
||||
<h2>{t('settings.theme')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
{theme.enableThemeScheduler && (
|
||||
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('settings.themeSchedulerActiveHint')}
|
||||
</div>
|
||||
)}
|
||||
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Clock size={18} />
|
||||
<h2>{t('settings.themeSchedulerTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div className="settings-toggle-row">
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{t('settings.themeSchedulerEnable')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.themeSchedulerEnableSub')}</div>
|
||||
</div>
|
||||
<label className="toggle-switch" aria-label={t('settings.themeSchedulerEnable')}>
|
||||
<input type="checkbox" checked={theme.enableThemeScheduler} onChange={e => theme.setEnableThemeScheduler(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
{theme.enableThemeScheduler && (() => {
|
||||
const themeOptions = THEME_GROUPS.flatMap(g =>
|
||||
g.themes.map(th => ({ value: th.id, label: th.label, group: g.group }))
|
||||
);
|
||||
const use12h = i18n.language === 'en';
|
||||
const hourOptions = Array.from({ length: 24 }, (_, i) => {
|
||||
const value = String(i).padStart(2, '0');
|
||||
const label = use12h
|
||||
? `${i % 12 === 0 ? 12 : i % 12} ${i < 12 ? 'AM' : 'PM'}`
|
||||
: value;
|
||||
return { value, label };
|
||||
});
|
||||
const minuteOptions = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'].map(m => ({ value: m, label: m }));
|
||||
const dayH = theme.timeDayStart.split(':')[0];
|
||||
const dayM = theme.timeDayStart.split(':')[1];
|
||||
const nightH = theme.timeNightStart.split(':')[0];
|
||||
const nightM = theme.timeNightStart.split(':')[1];
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1rem' }}>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayTheme')}</label>
|
||||
<CustomSelect value={theme.themeDay} onChange={theme.setThemeDay} options={themeOptions} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerDayStart')}</label>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<CustomSelect value={dayH} onChange={v => theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} />
|
||||
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
|
||||
<CustomSelect value={dayM} onChange={v => theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightTheme')}</label>
|
||||
<CustomSelect value={theme.themeNight} onChange={theme.setThemeNight} options={themeOptions} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="settings-label" style={{ marginBottom: 6 }}>{t('settings.themeSchedulerNightStart')}</label>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
<CustomSelect value={nightH} onChange={v => theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} />
|
||||
<span style={{ color: 'var(--text-muted)', fontWeight: 600 }}>:</span>
|
||||
<CustomSelect value={nightM} onChange={v => theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<ZoomIn size={18} />
|
||||
<h2>{t('settings.uiScaleTitle')}</h2>
|
||||
</div>
|
||||
<div className="settings-card">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
|
||||
{Math.round(fontStore.uiScale * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.8}
|
||||
max={1.5}
|
||||
step={0.05}
|
||||
value={fontStore.uiScale}
|
||||
onChange={e => fontStore.setUiScale(parseFloat(e.target.value))}
|
||||
className="ui-scale-slider"
|
||||
/>
|
||||
<div style={{ position: 'relative', height: 24 }}>
|
||||
{[80, 90, 100, 110, 125, 150].map(p => {
|
||||
const pct = ((p / 100) - 0.8) / (1.5 - 0.8) * 100;
|
||||
const active = Math.round(fontStore.uiScale * 100) === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${pct}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
opacity: active ? 1 : 0.5,
|
||||
color: active ? 'var(--accent)' : undefined,
|
||||
}}
|
||||
onClick={() => fontStore.setUiScale(p / 100)}
|
||||
>
|
||||
{p}%
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<Type size={18} />
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { getAlbumList, getArtists, getGenres, getRandomSongs, getStarred, SubsonicAlbum, SubsonicArtist, SubsonicGenre, SubsonicSong } from '../api/subsonic';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getAlbumList, getArtists, getGenres, getRandomSongs, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
|
||||
import AlbumRow from '../components/AlbumRow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -40,27 +39,6 @@ export default function Statistics() {
|
||||
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
|
||||
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
|
||||
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
|
||||
const [starredSongs, setStarredSongs] = useState<SubsonicSong[]>([]);
|
||||
const [topArtists, setTopArtists] = useState<SubsonicArtist[]>([]);
|
||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||
|
||||
const topSongs = useMemo(() => {
|
||||
const map = new Map<string, SubsonicSong>(starredSongs.map(s => [s.id, { ...s, userRating: userRatingOverrides[s.id] ?? s.userRating }]));
|
||||
// Songs not yet in starredSongs but rated via override (e.g. from queue or currentTrack)
|
||||
const candidates = currentTrack ? [currentTrack, ...queue] : queue;
|
||||
for (const t of candidates) {
|
||||
const r = userRatingOverrides[t.id];
|
||||
if (r && !map.has(t.id)) {
|
||||
map.set(t.id, { id: t.id, title: t.title, artist: t.artist, album: t.album, albumId: t.albumId, userRating: r } as SubsonicSong);
|
||||
}
|
||||
}
|
||||
return [...map.values()]
|
||||
.filter(s => (s.userRating ?? 0) > 0)
|
||||
.sort((a, b) => (b.userRating ?? 0) - (a.userRating ?? 0))
|
||||
.slice(0, 10);
|
||||
}, [starredSongs, userRatingOverrides, queue, currentTrack]);
|
||||
const [artistCount, setArtistCount] = useState<number | null>(null);
|
||||
const [totalSongs, setTotalSongs] = useState<number | null>(null);
|
||||
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
|
||||
@@ -87,8 +65,7 @@ export default function Statistics() {
|
||||
getAlbumList('highest', 12).catch(() => []),
|
||||
getArtists().catch(() => []),
|
||||
getGenres().catch(() => []),
|
||||
getStarred().catch(() => ({ albums: [], artists: [], songs: [] })),
|
||||
]).then(([rc, fr, hi, a, g, starred]) => {
|
||||
]).then(([rc, fr, hi, a, g]) => {
|
||||
setRecent(rc);
|
||||
setFrequent(fr);
|
||||
setHighest(hi);
|
||||
@@ -97,13 +74,6 @@ export default function Statistics() {
|
||||
setTotalAlbums(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.albumCount, 0));
|
||||
const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
|
||||
setGenres(sorted);
|
||||
setStarredSongs(starred.songs);
|
||||
setTopArtists(
|
||||
[...starred.artists]
|
||||
.filter(a => (a.userRating ?? 0) > 0)
|
||||
.sort((a, b) => (b.userRating ?? 0) - (a.userRating ?? 0))
|
||||
.slice(0, 10),
|
||||
);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, [musicLibraryFilterVersion]);
|
||||
@@ -317,67 +287,6 @@ export default function Statistics() {
|
||||
showRating
|
||||
/>
|
||||
|
||||
{/* Top Rated Songs + Artists */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '0.5rem' }}>
|
||||
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
||||
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
|
||||
{t('statistics.topRatedSongs')}
|
||||
</h3>
|
||||
{topSongs.length === 0 ? (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{t('statistics.noRatedSongs')}</p>
|
||||
) : (
|
||||
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{topSongs.map((song, i) => (
|
||||
<li key={song.id} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', cursor: song.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => song.albumId && navigate(`/album/${song.albumId}`)}>
|
||||
<span style={{ fontSize: '1.1rem', fontWeight: 800, color: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 1 : 0.5, lineHeight: 1, flexShrink: 0, width: '1.5rem' }}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.title}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.artist}</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: 'var(--accent)', flexShrink: 0, letterSpacing: '1px' }}>
|
||||
{'★'.repeat(song.userRating!)}{'☆'.repeat(5 - song.userRating!)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--glass-bg)', border: '1px solid var(--glass-border)', borderRadius: '12px', padding: '1.25rem', backdropFilter: 'blur(8px)' }}>
|
||||
<h3 style={{ fontSize: '0.7rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--accent)', marginBottom: '1rem' }}>
|
||||
{t('statistics.topRatedArtists')}
|
||||
</h3>
|
||||
{topArtists.length === 0 ? (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{t('statistics.noRatedArtists')}</p>
|
||||
) : (
|
||||
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{topArtists.map((artist, i) => (
|
||||
<li key={artist.id} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}>
|
||||
<span style={{ fontSize: '1.1rem', fontWeight: 800, color: i === 0 ? 'var(--accent)' : 'var(--text-muted)', opacity: i === 0 ? 1 : 0.5, lineHeight: 1, flexShrink: 0, width: '1.5rem' }}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{artist.name}</div>
|
||||
{(artist.albumCount ?? 0) > 0 && (
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||
{t('artistDetail.albumCount_other', { count: artist.albumCount })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: 'var(--accent)', flexShrink: 0, letterSpacing: '1px' }}>
|
||||
{'★'.repeat(artist.userRating!)}{'☆'.repeat(5 - artist.userRating!)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last.fm Stats */}
|
||||
{lastfmIsConfigured() && (
|
||||
<section style={{ marginTop: '2rem' }}>
|
||||
|
||||
@@ -227,7 +227,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
minimizeToTray: false,
|
||||
discordRichPresence: false,
|
||||
enableAppleMusicCoversDiscord: false,
|
||||
useCustomTitlebar: true,
|
||||
useCustomTitlebar: false,
|
||||
nowPlayingEnabled: false,
|
||||
lyricsServerFirst: true,
|
||||
showFullscreenLyrics: true,
|
||||
|
||||
@@ -6,6 +6,8 @@ export type FontId = 'inter' | 'outfit' | 'dm-sans' | 'nunito' | 'rubik' | 'spac
|
||||
interface FontState {
|
||||
font: FontId;
|
||||
setFont: (font: FontId) => void;
|
||||
uiScale: number;
|
||||
setUiScale: (scale: number) => void;
|
||||
}
|
||||
|
||||
export const useFontStore = create<FontState>()(
|
||||
@@ -13,6 +15,8 @@ export const useFontStore = create<FontState>()(
|
||||
(set) => ({
|
||||
font: 'lexend',
|
||||
setFont: (font) => set({ font }),
|
||||
uiScale: 1.0,
|
||||
setUiScale: (uiScale) => set({ uiScale }),
|
||||
}),
|
||||
{ name: 'psysonic_font' }
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
+99
-76
@@ -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,82 +150,97 @@ 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(
|
||||
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.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 => {
|
||||
set(state => ({
|
||||
jobs: state.jobs.map(j =>
|
||||
j.trackId === song.id && j.albumId === albumId
|
||||
? { ...j, status: 'downloading' }
|
||||
: j,
|
||||
),
|
||||
}));
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
set(state => ({
|
||||
tracks: {
|
||||
...state.tracks,
|
||||
[`${serverId}:${song.id}`]: {
|
||||
id: song.id,
|
||||
serverId,
|
||||
localPath,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
suffix,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
coverArt: song.coverArt,
|
||||
year: song.year,
|
||||
genre: song.genre,
|
||||
replayGainTrackDb: song.replayGain?.trackGain,
|
||||
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,
|
||||
),
|
||||
}));
|
||||
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') {
|
||||
if (msg === 'VOLUME_NOT_FOUND' && !cancelledDownloads.has(albumId)) {
|
||||
cancelledDownloads.add(albumId);
|
||||
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,
|
||||
),
|
||||
}));
|
||||
return { song, suffix, localPath: null as string | null, error: msg };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// 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,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
albumId: song.albumId,
|
||||
artistId: song.artistId,
|
||||
suffix,
|
||||
duration: song.duration,
|
||||
bitRate: song.bitRate,
|
||||
coverArt: song.coverArt,
|
||||
year: song.year,
|
||||
genre: song.genre,
|
||||
replayGainTrackDb: song.replayGain?.trackGain,
|
||||
replayGainAlbumDb: song.replayGain?.albumGain,
|
||||
replayGainPeak: song.replayGain?.trackPeak,
|
||||
cachedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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' };
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear completed jobs after a short delay
|
||||
// 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 };
|
||||
});
|
||||
|
||||
@@ -264,7 +264,7 @@ function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTi
|
||||
savePlayQueue(ids, currentTrack?.id, pos).catch(err => {
|
||||
console.error('Failed to sync play queue to server', err);
|
||||
});
|
||||
}, 1500);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
|
||||
|
||||
@@ -20,6 +20,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
|
||||
{ id: 'playlists', visible: true },
|
||||
{ id: 'mostPlayed', visible: true },
|
||||
{ id: 'radio', visible: true },
|
||||
{ id: 'folderBrowser', visible: false },
|
||||
{ id: 'statistics', visible: true },
|
||||
{ id: 'help', visible: true },
|
||||
];
|
||||
|
||||
@@ -6,6 +6,30 @@ type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowsto
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
enableThemeScheduler: boolean;
|
||||
setEnableThemeScheduler: (v: boolean) => void;
|
||||
themeDay: string;
|
||||
setThemeDay: (v: string) => void;
|
||||
themeNight: string;
|
||||
setThemeNight: (v: string) => void;
|
||||
timeDayStart: string;
|
||||
setTimeDayStart: (v: string) => void;
|
||||
timeNightStart: string;
|
||||
setTimeNightStart: (v: string) => void;
|
||||
}
|
||||
|
||||
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
|
||||
if (!state.enableThemeScheduler) return state.theme;
|
||||
const now = new Date();
|
||||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
const [dh, dm] = state.timeDayStart.split(':').map(Number);
|
||||
const [nh, nm] = state.timeNightStart.split(':').map(Number);
|
||||
const dayMins = dh * 60 + dm;
|
||||
const nightMins = nh * 60 + nm;
|
||||
const isDay = dayMins < nightMins
|
||||
? nowMins >= dayMins && nowMins < nightMins
|
||||
: nowMins >= dayMins || nowMins < nightMins;
|
||||
return isDay ? state.themeDay : state.themeNight;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
@@ -13,6 +37,16 @@ export const useThemeStore = create<ThemeState>()(
|
||||
(set) => ({
|
||||
theme: 'mocha',
|
||||
setTheme: (theme) => set({ theme }),
|
||||
enableThemeScheduler: false,
|
||||
setEnableThemeScheduler: (v) => set({ enableThemeScheduler: v }),
|
||||
themeDay: 'latte',
|
||||
setThemeDay: (v) => set({ themeDay: v }),
|
||||
themeNight: 'mocha',
|
||||
setThemeNight: (v) => set({ themeNight: v }),
|
||||
timeDayStart: '07:00',
|
||||
setTimeDayStart: (v) => set({ timeDayStart: v }),
|
||||
timeNightStart: '19:00',
|
||||
setTimeNightStart: (v) => set({ timeNightStart: v }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_theme',
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
}));
|
||||
@@ -2384,6 +2384,18 @@
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.settings-hint-info {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ─ Help Page ─ */
|
||||
.help-list {
|
||||
display: flex;
|
||||
@@ -6739,3 +6751,261 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ─── UI Scale Slider ─── */
|
||||
.ui-scale-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border-subtle);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-scale-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
|
||||
.ui-scale-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Folder Browser (Miller Columns) ──────────────────────────────────────── */
|
||||
|
||||
.folder-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.folder-browser-title {
|
||||
padding: 1.5rem 1.5rem 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-browser-columns {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* height fallback for browsers that don't support flex: 1 correctly here */
|
||||
height: calc(100vh - var(--player-height, 88px) - var(--titlebar-height, 0px) - 70px);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.folder-browser-columns::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.folder-col {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.folder-col-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.folder-col-error {
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
|
||||
.folder-col-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.38rem 0.6rem 0.38rem 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.folder-col-row:hover {
|
||||
background: var(--bg-hover, color-mix(in srgb, var(--accent) 8%, transparent));
|
||||
}
|
||||
|
||||
.folder-col-row.selected {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.folder-col-row.selected .folder-col-chevron {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.folder-col-icon {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.folder-col-row.selected .folder-col-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.folder-col-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-col-chevron {
|
||||
flex-shrink: 0;
|
||||
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%); }
|
||||
}
|
||||
|
||||
@@ -40,6 +40,23 @@
|
||||
"player player player";
|
||||
}
|
||||
|
||||
/* Resize grips — replace the native GTK grips lost when decorations: false */
|
||||
.app-shell[data-titlebar]::before,
|
||||
.app-shell[data-titlebar]::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle, var(--text-muted) 1.5px, transparent 1.5px);
|
||||
background-size: 4px 4px;
|
||||
opacity: 0.35;
|
||||
z-index: 9999;
|
||||
}
|
||||
.app-shell[data-titlebar]::before { left: 0; }
|
||||
.app-shell[data-titlebar]::after { right: 0; }
|
||||
|
||||
.titlebar {
|
||||
grid-area: titlebar;
|
||||
display: flex;
|
||||
@@ -712,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); }
|
||||
}
|
||||
@@ -1563,6 +1602,15 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-shell[data-mobile][data-titlebar] {
|
||||
grid-template-rows: var(--titlebar-height) 1fr auto auto;
|
||||
grid-template-areas:
|
||||
"titlebar"
|
||||
"main"
|
||||
"bottomnav"
|
||||
"player";
|
||||
}
|
||||
|
||||
/* Hide the sidebar collapse button on mobile */
|
||||
.app-shell[data-mobile] .collapse-btn {
|
||||
display: none;
|
||||
|
||||
@@ -127,6 +127,8 @@ const MIN_CONTRAST = 4.5;
|
||||
*/
|
||||
export function extractCoverColors(imageUrl: string): Promise<CoverColors> {
|
||||
if (!imageUrl) return Promise.resolve({ accent: '' });
|
||||
// Logo fallback has no meaningful color — skip extraction and use theme accent
|
||||
if (imageUrl.includes('logo-psysonic')) return Promise.resolve({ accent: '' });
|
||||
|
||||
return new Promise(resolve => {
|
||||
const img = new Image();
|
||||
|
||||
Reference in New Issue
Block a user