Compare commits

..

5 Commits

Author SHA1 Message Date
Psychotoxical 8cd4cdcd64 feat: v1.33.0 — Fullscreen Player redesign, Norwegian, configurable preload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 02:00:48 +02:00
Psychotoxical a32ca64792 Merge pull request #101: Added Norwegian language
Added Norwegian language
2026-04-06 01:42:56 +02:00
zz55zz 939abace35 Added Norwegian language
Added Norwegian language
2026-04-05 13:15:22 -08:00
Psychotoxical 4f7236e986 Update CHANGELOG.md 2026-04-05 20:07:17 +02:00
Psychotoxical 9be0d8dfa9 feat: v1.32.0 — The Big Easter Update
Internet Radio full release (HTML5 engine, card UI, RadioDirectoryModal,
cover upload), Backup/Restore, Albums year filter, Statistics Library
Insights (playtime/genres/formats), Playlist cover upload, resizable
tracklist columns for Playlists & Favorites, crossfade fine control,
Settings Storage tab redesign, various fixes and UI polish.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:34:26 +02:00
44 changed files with 5821 additions and 1634 deletions
+3
View File
@@ -123,6 +123,9 @@ jobs:
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
tagName: app-v${{ needs.create-release.outputs.package_version }}
releaseName: Psysonic v${{ needs.create-release.outputs.package_version }}
releaseDraft: true
args: ${{ matrix.settings.args }}
- name: sign and upload Windows NSIS updater bundle
+63
View File
@@ -5,6 +5,69 @@ 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.33.0] - 2026-04-06
### Added
- **Norwegian (Bokmål) translation** *(PR [#101](https://github.com/Psychotoxical/psysonic/pull/101) by [@zz5zz](https://github.com/zz5zz))*: Psysonic is now fully translated into Norwegian Bokmål — selectable in Settings → Appearance.
- **Configurable next-track preload** *(Issue [#102](https://github.com/Psychotoxical/psysonic/issues/102))*: A new setting in Settings → Playback controls when Psysonic starts buffering the next track. Three modes available:
- **Balanced** (default) — begins buffering 30 s before the end of the current track (previous behaviour).
- **Early** — begins buffering after just 5 s of playback, maximising reliability on slow connections.
- **Custom** — set the exact threshold (5 120 s before the end) via a slider.
- **Tray icon visibility toggle**: A new toggle in Settings → App Behavior lets you show or hide the system tray icon. When disabled, the icon is fully removed from the notification area / menu bar.
### Changed
- **Fullscreen Player — complete redesign**: The Ambient Stage has been rebuilt from the ground up.
- **Animated mesh background**: A GPU-only animated dark gradient mesh replaces the static blurred cover art background — smooth, performant, no layout repaints.
- **Artist portrait**: The right half of the screen now shows the artist's image (loaded from the server), crossfading smoothly on every track change. Falls back to the album cover if no artist image is available.
- **Bottom seekbar**: The seekbar is now pinned to the very bottom edge, spanning the full width, with elapsed and remaining timestamps above it.
- **Heart button**: You can now star/unstar the currently playing track directly from the Fullscreen Player without leaving the view.
- Removed the marquee-scrolling title in favour of a large, wrapping typographic layout.
- **Star buttons** — all star/favourite buttons across the app (Player Bar, Album Header, Album Tracklist, Queue Panel) now use the CSS class `.is-starred` instead of inline color overrides, making them trivially themeable.
### Fixed
- **macOS — HTTP audio streams**: Added `NSAppTransportSecurity` / `NSAllowsArbitraryLoads` to `Info.plist`. Without this, App Transport Security silently blocked HTTP radio streams and non-HTTPS Navidrome servers from loading audio in WKWebView on macOS.
---
## [1.32.0] - 2026-04-05 — *The Big Easter Update* 🐣
### Added
- **Custom Offline Storage Directory (#95)**: You can now specify a custom directory for the offline library in Settings → Storage & Downloads. This is perfect for offloading your internal drive to an SD card or external HDD.
- **Robust Volume Handling**: The app now automatically detects if a configured external storage medium is missing and provides a clear "Volume not found" notification instead of failing silently or attempting to download to a non-existent path.
- **Internet Radio — full release**: The Radio page is now accessible from the sidebar. Complete UI rewrite to a card-based layout (cover art, name, edit/homepage buttons) consistent with the Playlists look. Covers can be uploaded or removed via a hover menu directly on the card.
- **Internet Radio — Edit Modal**: A dedicated modal lets you change station name, stream URL, and homepage URL, and upload or remove cover art.
- **Internet Radio — Radio Browser directory** *(via [radio-browser.info](https://www.radio-browser.info))*: Discover new stations directly inside Psysonic. Top stations by vote are shown as suggestions; a debounced search finds stations by name. Favicon images can be imported as cover art in one click.
- **Settings — Backup & Restore**: Export all your settings (servers, theme, font, keybindings, EQ preset, sidebar order) to a single JSON file and import them on another machine or after a reinstall. Available in Settings → Storage.
- **Albums — Year Range Filter**: A From/To year input now appears in the Albums toolbar alongside the existing genre filter. Filtering by year and by genre can be combined; clearing both inputs returns to the default view.
- **Statistics — Library Insights** *(requested via [#88](https://github.com/Psychotoxical/psysonic/issues/88))*:
- **Total Playtime** card: computed in the background by paginating your full album list (up to 5 000 albums). Shows `≥ Xh Ym` if the library is larger.
- **Genre Insights**: Top 10 genres ranked by song count with proportional progress bars.
- **Format Distribution**: Codec breakdown from a random 500-track sample — shows format name and percentage.
- **Playlist Detail — Cover Upload**: Change or remove a playlist's cover image via the hover menu that appears on the hero artwork — no external tool needed.
- **Tracklist columns — Playlists & Favorites** *(work in progress)*: PlaylistDetail and Favorites now support the same resizable, configurable column system introduced in v1.31.0 for Album tracklists. Column widths and visibility are persisted independently per page. The feature is still being refined.
### Changed
- **Crossfade — fine-grained control**: The crossfade duration slider now ranges from 0.1 s to 10 s in 0.1 s steps (previously 1 s minimum, 0.5 s steps). The current value is shown with one decimal place.
- **Settings — Storage tab redesign**: The "Offline Library" section now has a short description and includes Cache settings. The "Downloads" section is now labelled "ZIP Export & Archiving". Both sections have been visually consolidated.
- **Artists page — Load More button** *(reported via [#90](https://github.com/Psychotoxical/psysonic/issues/90))*: The button is now styled as `btn-primary` with a `ChevronDown` icon and proper spacing. Previously it was an unstyled ghost button with no visual affordance.
- **Tracklist layout consistency**: The Play-button column is now uniformly 60 px and the title column uses `minmax(150px, 1fr)` across all list views — Search Results, Artist Detail, Random Mix, and Advanced Search now match the Album tracklist layout.
- **Internet Radio — HTML5 playback**: Radio now streams via the browser's native `<audio>` element instead of a custom Rust pipeline. This improves compatibility with AAC/MP3/HLS streams.
- **AppUpdater — error visibility** *(experimental, still in progress)*: Update failures are now shown inside the update card rather than silently logged. Auto-update remains experimental — a direct GitHub Releases link is always shown as a fallback.
- **Queue panel — radio drag**: Dragging a radio station card onto the queue is now silently rejected instead of causing an error.
### Fixed
- **PlayerBar stuck on Radio info**: Switching from an Internet Radio station to a regular track no longer leaves the station name and cover in the player bar. `playTrack` now clears `currentRadio` state and stops the audio element immediately.
- **Radio favourite icon**: The heart icon is now correctly used for favourite radio stations on both the Internet Radio page and the Favourites page. It was incorrectly showing a star.
- **Offline track deletion — orphaned directories**: Deleting a cached track now removes empty parent directories up to the configured base directory. Uses `std::fs::remove_dir` (safe — only removes empty directories) to avoid accidental data loss.
---
## [1.31.0] - 2026-04-04
> **Note:** This is likely the last update for the coming week — taking a short break. See you on the other side. ☀️
+23 -4
View File
@@ -1,5 +1,5 @@
<div align="center">
<img src="public/logo-psysonic.png" alt="Psysonic Logo" width="200"/>
<img src="public/psysonic-inapp-logo.svg" alt="Psysonic Logo" width="200"/>
<h1>Psysonic</h1>
<p><strong>A modern, gorgeous, and blazing fast desktop client for Subsonic API compatible music servers (Navidrome, Gonic, etc.).</strong></p>
@@ -80,10 +80,29 @@ Designed specifically for users hosting their own music via Navidrome or other S
Navigate to the [Releases](https://github.com/Psychotoxical/psysonic/releases) page and download the installer for your operating system.
- **Windows**: `.exe` (NSIS installer)
### 🐧 Linux
- **Ubuntu / Debian**: `.deb` from GitHub Releases
- **Fedora / RHEL**: `.rpm` from GitHub Releases
### 🍎 macOS
- **macOS**: `.dmg` (Universal or Apple Silicon)
- **Linux (Ubuntu/Debian)**: `.deb` from GitHub Releases
- **Linux (Fedora/RHEL)**: `.rpm` from GitHub Releases
> [!WARNING]
> **Gatekeeper Note:**
> Since the app is released without an Apple Developer certificate, macOS will block it by default. To bypass this, run the following command in the Terminal after moving the app to the Applications folder:
> ```sh
> xattr -cr /Applications/Psysonic.app
> ```
### 🪟 Windows
- **Windows**: `.exe` (NSIS installer)
> [!WARNING]
> **SmartScreen Note:**
> Windows SmartScreen might show a warning because the installer isn't signed with an expensive developer certificate. Click on **"More info"** and then **"Run anyway"**.
## 📦 Installation (Arch Linux / AUR)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.31.0",
"version": "1.33.0",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.31.0
pkgver=1.33.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+28 -1
View File
@@ -2577,6 +2577,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -3483,13 +3493,14 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.31.0"
version = "1.33.0"
dependencies = [
"biquad",
"discord-rich-presence",
"futures-util",
"md5",
"reqwest 0.12.28",
"ringbuf",
"rodio",
"serde",
"serde_json",
@@ -3732,6 +3743,7 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -3830,6 +3842,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "ringbuf"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79abed428d1fd2a128201cec72c5f6938e2da607c6f3745f769fabea399d950a"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rodio"
version = "0.19.0"
@@ -5658,6 +5679,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.31.0"
version = "1.33.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
@@ -32,11 +32,12 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.19", default-features = false, features = ["symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
reqwest = { version = "0.12", features = ["stream", "json"] }
reqwest = { version = "0.12", features = ["stream", "json", "multipart"] }
futures-util = "0.3"
md5 = "0.7"
tokio = { version = "1", features = ["rt", "time"] }
biquad = "0.4"
ringbuf = "0.3"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
+9
View File
@@ -7,5 +7,14 @@
(triggered by CoreAudio enumerating input devices during init). -->
<key>NSMicrophoneUsageDescription</key>
<string>Psysonic does not use the microphone. This prompt is triggered by the audio subsystem initializing output devices.</string>
<!-- Allow HTTP (non-TLS) connections so that internet radio streams and
Navidrome servers running without HTTPS can load media in WKWebView.
Without this, macOS App Transport Security blocks HTTP audio src. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
+3
View File
@@ -17,10 +17,13 @@
"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",
+1 -1
View File
@@ -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","fs:default","fs:allow-write-file","fs:allow-mkdir","fs:scope-download-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","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","updater:default","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-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","updater:default","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
+583 -237
View File
@@ -1,9 +1,10 @@
use std::collections::VecDeque;
use std::io::{Cursor, Read, Seek, SeekFrom};
use std::sync::{Arc, Condvar, Mutex};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant};
use ringbuf::{HeapConsumer, HeapProducer, HeapRb};
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
use rodio::{Sink, Source};
use rodio::source::UniformSourceIterator;
@@ -414,120 +415,440 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
}
}
// ─── SizedCursorSource — MediaSource with correct byte_len ────────────────────
// ─── Internet Radio v2 — Lock-Free SPSC + ICY Metadata + Hybrid Pause ────────
//
// ─── RadioBuffer — streaming MediaSource for live HTTP radio ─────────────────
// HTTP task (tokio)
// └─[IcyInterceptor]─► HeapProducer<u8>
// │ (4 MB HeapRb, lock-free)
// HeapConsumer<u8>
// │
// AudioStreamReader (Read + Seek + MediaSource)
// │
// SizedDecoder (symphonia)
// │
// rodio Sink
//
// Bridges an async reqwest byte-stream (download task) into a synchronous
// Read+Seek interface that symphonia / rodio can consume.
// Pause modes:
// Logical pause — sink.pause(); download task keeps filling (time-shift).
// Hard pause — buffer ≥ RADIO_HARD_PAUSE_THRESH full + paused ≥ 5 s
// → TCP disconnect, is_hard_paused = true.
// Resume (warm) — sink.play(); buffer drains seamlessly.
// Resume (cold) — new HeapRb + new GET; consumer swapped in AudioStreamReader.
//
// Back-pressure: the download task pauses when the ring buffer exceeds 4 MB
// (~4 min at 128 kbps). Read() blocks (via Condvar) until data arrives so
// rodio's audio thread can decode in real time. is_seekable() = false so
// symphonia never tries to seek backward into consumed data.
// New Tauri event: "radio:metadata" → String (ICY StreamTitle)
const RADIO_BUF_MAX: usize = 256 * 1024;
/// 256 KB on the heap — ≈16 s at 128 kbps, ≈6 s at 320 kbps.
/// Small enough that stale audio drains within a few seconds on reconnect;
/// large enough to absorb brief network hiccups without stuttering.
const RADIO_BUF_CAPACITY: usize = 256 * 1024;
/// Seconds at stall threshold while paused before hard-disconnect.
const RADIO_HARD_PAUSE_SECS: u64 = 5;
/// AudioStreamReader timeout: if no audio bytes arrive for this long → EOF.
const RADIO_READ_TIMEOUT_SECS: u64 = 15;
/// Sleep interval when ring buffer is empty (prevents CPU spin).
const RADIO_YIELD_MS: u64 = 2;
pub(crate) struct RadioInner {
data: VecDeque<u8>,
eof: bool,
// ── ICY Metadata State Machine ────────────────────────────────────────────────
//
// Shoutcast/Icecast embed metadata every `metaint` audio bytes:
//
// ┌──────────────────────┬───┬─────────────┐
// │ audio × metaint │ N │ meta × N×16 │ (repeating)
// └──────────────────────┴───┴─────────────┘
//
// N = 0 → no metadata this block. Metadata bytes are stripped so only
// pure audio reaches the ring buffer and Symphonia never sees text bytes.
enum IcyState {
/// Forwarding audio bytes; `remaining` counts down to the next boundary.
ReadingAudio { remaining: usize },
/// Next byte is the metadata length multiplier N.
ReadingLengthByte,
/// Accumulating N×16 metadata bytes.
ReadingMetadata { remaining: usize, buf: Vec<u8> },
}
struct IcyInterceptor {
state: IcyState,
metaint: usize,
}
impl IcyInterceptor {
fn new(metaint: usize) -> Self {
Self { metaint, state: IcyState::ReadingAudio { remaining: metaint } }
}
/// Feed a raw HTTP chunk.
/// Appends only audio bytes to `audio_out`.
/// Returns `Some(IcyMeta)` when a StreamTitle is extracted.
fn process(&mut self, input: &[u8], audio_out: &mut Vec<u8>) -> Option<IcyMeta> {
let mut extracted: Option<IcyMeta> = None;
let mut i = 0;
while i < input.len() {
match &mut self.state {
IcyState::ReadingAudio { remaining } => {
let n = (input.len() - i).min(*remaining);
audio_out.extend_from_slice(&input[i..i + n]);
i += n;
*remaining -= n;
if *remaining == 0 {
self.state = IcyState::ReadingLengthByte;
}
}
IcyState::ReadingLengthByte => {
let len_n = input[i] as usize;
i += 1;
self.state = if len_n == 0 {
IcyState::ReadingAudio { remaining: self.metaint }
} else {
IcyState::ReadingMetadata {
remaining: len_n * 16,
buf: Vec::with_capacity(len_n * 16),
}
};
}
IcyState::ReadingMetadata { remaining, buf } => {
let n = (input.len() - i).min(*remaining);
buf.extend_from_slice(&input[i..i + n]);
i += n;
*remaining -= n;
if *remaining == 0 {
let bytes = std::mem::take(buf);
extracted = parse_icy_meta(&bytes);
self.state = IcyState::ReadingAudio { remaining: self.metaint };
}
}
}
}
extracted
}
}
/// ICY metadata parsed from a raw metadata block.
#[derive(serde::Serialize, Clone)]
pub(crate) struct IcyMeta {
pub title: String,
/// `true` when `StreamUrl='0'` — indicates a CDN-injected ad/promo.
pub is_ad: bool,
}
/// Extract `StreamTitle` and `StreamUrl` from a raw ICY metadata block.
/// Tolerates null padding and non-UTF-8 bytes (lossy conversion).
fn parse_icy_meta(raw: &[u8]) -> Option<IcyMeta> {
let s = String::from_utf8_lossy(raw);
let s = s.trim_end_matches('\0');
const TITLE_TAG: &str = "StreamTitle='";
let title_start = s.find(TITLE_TAG)? + TITLE_TAG.len();
let title_rest = &s[title_start..];
// find (not rfind) — rfind would skip past StreamUrl and corrupt the title
let title_end = title_rest.find("';")?;
let title = title_rest[..title_end].trim().to_string();
if title.is_empty() {
return None;
}
const URL_TAG: &str = "StreamUrl='";
let stream_url = s.find(URL_TAG).map(|pos| {
let rest = &s[pos + URL_TAG.len()..];
let end = rest.find("';").unwrap_or(rest.len());
rest[..end].trim().to_string()
}).unwrap_or_default();
Some(IcyMeta { title, is_ad: stream_url == "0" })
}
// ── AudioStreamReader — SPSC consumer → std::io::Read ────────────────────────
//
// Bridges HeapConsumer<u8> (non-blocking) into the synchronous Read interface
// that Symphonia requires. Designed to run inside tokio::task::spawn_blocking.
//
// Empty buffer: sleeps RADIO_YIELD_MS ms, retries. Never busy-spins.
// Timeout: after RADIO_READ_TIMEOUT_SECS with no data → TimedOut.
// Generation: if gen_arc != self.gen → Ok(0) (EOF; new track started).
// Reconnect: audio_resume sends a fresh HeapConsumer via new_cons_rx.
// On the next read() we drain the channel (keep latest) and swap.
struct AudioStreamReader {
cons: HeapConsumer<u8>,
/// Delivers fresh consumers on hard-pause reconnect (unbounded; drain to latest).
/// Wrapped in Mutex so AudioStreamReader is Sync (required by symphonia::MediaSource).
/// No real contention: only the audio thread ever calls read().
new_cons_rx: Mutex<std::sync::mpsc::Receiver<HeapConsumer<u8>>>,
deadline: std::time::Instant,
gen_arc: Arc<AtomicU64>,
gen: u64,
/// Monotonic byte offset for SeekFrom::Current(0) "tell" (Symphonia probe).
pos: u64,
}
// The read-side: given to symphonia / rodio.
struct RadioBuffer {
inner: Arc<(Mutex<RadioInner>, Condvar)>,
}
// The write-side: held by the async download task.
pub struct RadioFeed {
pub inner: Arc<(Mutex<RadioInner>, Condvar)>,
}
impl RadioBuffer {
fn new() -> (RadioBuffer, RadioFeed) {
let arc = Arc::new((
Mutex::new(RadioInner { data: VecDeque::new(), eof: false, pos: 0 }),
Condvar::new(),
));
(RadioBuffer { inner: arc.clone() }, RadioFeed { inner: arc })
}
}
impl RadioFeed {
pub fn push(&self, chunk: &[u8]) {
let (lock, cvar) = &*self.inner;
let mut g = lock.lock().unwrap();
g.data.extend(chunk.iter().copied());
cvar.notify_one();
}
pub fn is_full(&self) -> bool {
let (lock, _) = &*self.inner;
lock.lock().unwrap().data.len() >= RADIO_BUF_MAX
}
pub fn flush(&self) {
let (lock, _) = &*self.inner;
lock.lock().unwrap().data.clear();
}
pub fn close(&self) {
let (lock, cvar) = &*self.inner;
let mut g = lock.lock().unwrap();
g.eof = true;
cvar.notify_all();
}
}
impl Read for RadioBuffer {
impl Read for AudioStreamReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let (lock, cvar) = &*self.inner;
let mut g = lock.lock().unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while g.data.is_empty() && !g.eof {
let rem = deadline.saturating_duration_since(std::time::Instant::now());
if rem.is_zero() {
eprintln!("[radio] RadioBuffer::read() timed out — no data for 10 s");
return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "radio: no data after 10 s"));
// EOF guard: new track started.
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
return Ok(0);
}
// Drain reconnect channel; keep only the most recently delivered consumer
// so a double-tap of resume doesn't leave stale data in place.
let mut newest: Option<HeapConsumer<u8>> = None;
while let Ok(c) = self.new_cons_rx.lock().unwrap().try_recv() {
newest = Some(c);
}
if let Some(c) = newest {
self.cons = c;
self.deadline =
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
}
loop {
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
return Ok(0);
}
let (new_g, _) = cvar.wait_timeout(g, rem).unwrap();
g = new_g;
let available = self.cons.len();
if available > 0 {
let n = buf.len().min(available);
let read = self.cons.pop_slice(&mut buf[..n]);
self.pos += read as u64;
// Reset deadline: data arrived, so connection is alive.
self.deadline =
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
return Ok(read);
}
if std::time::Instant::now() >= self.deadline {
eprintln!(
"[radio] AudioStreamReader: {}s without data → EOF",
RADIO_READ_TIMEOUT_SECS
);
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"radio: no data received",
));
}
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
}
if g.data.is_empty() {
eprintln!("[radio] RadioBuffer::read() → EOF (eof flag set, buffer empty)");
return Ok(0); // EOF
}
let n = buf.len().min(g.data.len());
for (i, b) in g.data.drain(..n).enumerate() {
buf[i] = b;
}
g.pos += n as u64;
// Notify downloader that buffer has drained below the cap.
cvar.notify_one();
Ok(n)
}
}
impl Seek for RadioBuffer {
impl Seek for AudioStreamReader {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
// Live streams cannot seek. Symphonia will not try because
// is_seekable() = false; the only call it makes is SeekFrom::Current(0)
// (tell) which we handle.
let (lock, _) = &*self.inner;
let g = lock.lock().unwrap();
match pos {
SeekFrom::Current(0) => Ok(g.pos),
_ => Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "radio stream: not seekable")),
SeekFrom::Current(0) => Ok(self.pos),
_ => Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"radio stream is not seekable",
)),
}
}
}
impl MediaSource for RadioBuffer {
impl MediaSource for AudioStreamReader {
fn is_seekable(&self) -> bool { false }
fn byte_len(&self) -> Option<u64> { None }
}
// ── Pause / Reconnect Coordination ───────────────────────────────────────────
pub(crate) struct RadioSharedFlags {
/// Set by audio_pause; cleared by audio_resume.
is_paused: AtomicBool,
/// Set by download task on hard disconnect; cleared on resume-reconnect.
is_hard_paused: AtomicBool,
/// Delivers a fresh HeapConsumer<u8> to AudioStreamReader on reconnect.
new_cons_tx: Mutex<std::sync::mpsc::Sender<HeapConsumer<u8>>>,
}
/// Live state for the current radio session, stored in AudioEngine.
/// Dropping this struct aborts the HTTP download task immediately.
pub(crate) struct RadioLiveState {
pub url: String,
pub gen: u64,
pub task: tokio::task::JoinHandle<()>,
pub flags: Arc<RadioSharedFlags>,
}
impl Drop for RadioLiveState {
fn drop(&mut self) { self.task.abort(); }
}
// ── HE-AAC / FDK-AAC Fallback ────────────────────────────────────────────────
//
// Symphonia 0.5.x: AAC-LC only. HE-AAC (AAC+) and HE-AACv2 lack SBR/PS →
// streams play at half speed with muffled audio.
//
// With Cargo feature "fdk-aac": FdkAacDecoder is tried first for CODEC_TYPE_AAC.
// Enable in Cargo.toml:
// symphonia-adapter-fdk-aac = { version = "0.1", optional = true }
// [features]
// fdk-aac = ["dep:symphonia-adapter-fdk-aac"]
fn try_make_radio_decoder(
params: &symphonia::core::codecs::CodecParameters,
opts: &DecoderOptions,
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
symphonia::default::get_codecs().make(params, opts)
}
// ── Async HTTP Download Task ──────────────────────────────────────────────────
//
// Lifecycle:
// 'outer loop — reconnect on TCP drop (up to MAX_RECONNECTS)
// 'inner loop — read HTTP chunks → ICY interceptor → push audio to ring buffer
//
// Hard-pause detection: if push_slice() returns 0 (buffer full) AND sink is
// paused AND that condition persists for RADIO_HARD_PAUSE_SECS → disconnect.
// Sets is_hard_paused = true so audio_resume knows it must reconnect.
async fn radio_download_task(
gen: u64,
gen_arc: Arc<AtomicU64>,
mut initial_response: Option<reqwest::Response>,
http_client: reqwest::Client,
url: String,
mut prod: HeapProducer<u8>,
flags: Arc<RadioSharedFlags>,
app: AppHandle,
) {
let mut bytes_total: u64 = 0;
// Counts consecutive failures (reset on each successful chunk).
// laut.fm and similar CDNs force-reconnect every ~700 KB; this is normal.
let mut reconnect_count: u32 = 0;
const MAX_CONSECUTIVE_FAILURES: u32 = 5;
let mut audio_scratch: Vec<u8> = Vec::with_capacity(65_536);
'outer: loop {
if gen_arc.load(Ordering::SeqCst) != gen { return; }
// ── Obtain response (initial or reconnect) ────────────────────────────
let response = match initial_response.take() {
Some(r) => r,
None => {
if reconnect_count >= MAX_CONSECUTIVE_FAILURES {
eprintln!("[radio] {MAX_CONSECUTIVE_FAILURES} consecutive failures — giving up");
break 'outer;
}
tokio::time::sleep(Duration::from_millis(500)).await;
if gen_arc.load(Ordering::SeqCst) != gen { return; }
match http_client
.get(&url)
.header("Icy-MetaData", "1")
.send()
.await
{
Ok(r) if r.status().is_success() => {
eprintln!("[radio] reconnected ({bytes_total} B so far)");
r
}
Ok(r) => {
eprintln!("[radio] reconnect: HTTP {} — giving up", r.status());
break 'outer;
}
Err(e) => {
eprintln!("[radio] reconnect error: {e} — giving up");
break 'outer;
}
}
}
};
// Parse ICY metaint from each response (consistent across reconnects).
let metaint: Option<usize> = response
.headers()
.get("icy-metaint")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok());
let mut icy = metaint.map(IcyInterceptor::new);
let mut byte_stream = response.bytes_stream();
// Stall timer: tracks how long push_slice() returns 0 while paused.
let mut stall_since: Option<std::time::Instant> = None;
'inner: loop {
if gen_arc.load(Ordering::SeqCst) != gen { return; }
// ── Back-pressure + hard-pause detection ──────────────────────────
if prod.is_full() {
if flags.is_paused.load(Ordering::Relaxed) {
let since = stall_since.get_or_insert(std::time::Instant::now());
if since.elapsed() >= Duration::from_secs(RADIO_HARD_PAUSE_SECS) {
let fill_pct = ((1.0
- prod.free_len() as f32 / RADIO_BUF_CAPACITY as f32)
* 100.0) as u32;
eprintln!(
"[radio] hard pause: {fill_pct}% full, \
paused >{RADIO_HARD_PAUSE_SECS}s → disconnecting"
);
flags.is_hard_paused.store(true, Ordering::Release);
return; // Drop HeapProducer → TCP connection released.
}
} else {
stall_since = None;
}
tokio::time::sleep(Duration::from_millis(50)).await;
continue 'inner;
}
stall_since = None;
// ── Read HTTP chunk ───────────────────────────────────────────────
match byte_stream.next().await {
Some(Ok(chunk)) => {
bytes_total += chunk.len() as u64;
// Successful data → reset consecutive-failure counter.
reconnect_count = 0;
audio_scratch.clear();
if let Some(ref mut interceptor) = icy {
if let Some(meta) = interceptor.process(&chunk, &mut audio_scratch) {
let label = if meta.is_ad { "[Ad]" } else { "" };
eprintln!("[radio] ICY StreamTitle: {}{}", label, meta.title);
let _ = app.emit("radio:metadata", &meta);
}
} else {
audio_scratch.extend_from_slice(&chunk);
}
// Push with per-chunk back-pressure: yield 5 ms if full mid-chunk.
let mut offset = 0;
while offset < audio_scratch.len() {
if gen_arc.load(Ordering::SeqCst) != gen { return; }
let pushed = prod.push_slice(&audio_scratch[offset..]);
if pushed == 0 {
tokio::time::sleep(Duration::from_millis(5)).await;
} else {
offset += pushed;
}
}
}
Some(Err(e)) => {
reconnect_count += 1;
eprintln!("[radio] stream error: {e} → reconnecting (consecutive #{reconnect_count})");
break 'inner;
}
None => {
reconnect_count += 1;
eprintln!("[radio] stream ended cleanly → reconnecting (consecutive #{reconnect_count})");
break 'inner;
}
}
} // 'inner
// Do NOT swap the ring buffer here. The remaining bytes in the buffer
// are still valid audio and will drain naturally during reconnect.
// Clearing it would cause an immediate underrun/glitch.
// The buffer is kept small (RADIO_BUF_CAPACITY) so stale audio drains
// within a few seconds rather than minutes.
} // 'outer
eprintln!("[radio] download task done ({bytes_total} B total)");
}
fn content_type_to_hint(ct: &str) -> Option<String> {
let ct = ct.to_ascii_lowercase();
if ct.contains("mpeg") || ct.contains("mp3") { Some("mp3".into()) }
else if ct.contains("aac") || ct.contains("aacp") { Some("aac".into()) }
else if ct.contains("ogg") { Some("ogg".into()) }
else if ct.contains("flac") { Some("flac".into()) }
else { None }
}
// ─── SizedCursorSource — correct byte_len for seekable in-memory sources ──────
//
// rodio's internal ReadSeekSource wraps Cursor<Vec<u8>> but hardcodes
@@ -689,8 +1010,7 @@ impl SizedDecoder {
let track_id = track.id;
// Live streams have no known total frame count → total_duration = None.
let total_duration = None;
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
.map_err(|e| format!("radio: codec init failed: {e}"))?;
let mut format = probed.format;
@@ -1055,6 +1375,9 @@ pub struct AudioEngine {
/// Instant (as nanos since UNIX epoch via Instant hack) of the last gapless
/// auto-advance. Commands arriving within 500 ms are rejected as ghost commands.
pub gapless_switch_at: Arc<AtomicU64>,
/// Active radio session state. None for regular (non-radio) tracks.
/// Dropping the value aborts the HTTP download task via RadioLiveState::Drop.
pub radio_state: Mutex<Option<RadioLiveState>>,
}
pub struct AudioCurrent {
@@ -1155,6 +1478,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
current_sample_rate: Arc::new(AtomicU32::new(0)),
current_channels: Arc::new(AtomicU32::new(2)),
gapless_switch_at: Arc::new(AtomicU64::new(0)),
radio_state: Mutex::new(None),
};
(engine, thread)
@@ -1759,38 +2083,95 @@ pub fn audio_pause(state: State<'_, AudioEngine>) {
if !sink.is_paused() {
let pos = cur.position();
sink.pause();
cur.paused_at = Some(pos);
cur.paused_at = Some(pos);
cur.play_started = None;
}
}
// Notify the download task so it can start measuring the hard-pause stall timer.
if let Some(rs) = state.radio_state.lock().unwrap().as_ref() {
rs.flags.is_paused.store(true, Ordering::Release);
}
}
/// Resume playback.
///
/// **Warm resume** (`is_hard_paused = false`): download task is still running,
/// buffer has buffered audio. `sink.play()` suffices.
///
/// **Cold resume** (`is_hard_paused = true`): TCP was dropped. A fresh 4 MB
/// ring buffer is created, its consumer is sent to `AudioStreamReader` (which
/// swaps it in on the next `read()`), and a new download task is spawned.
#[tauri::command]
pub fn audio_resume(state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink {
if sink.is_paused() {
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
sink.play();
cur.seek_offset = pos;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Result<(), String> {
// Detect radio hard-disconnect.
let reconnect_info = {
let guard = state.radio_state.lock().unwrap();
guard
.as_ref()
.filter(|rs| rs.flags.is_hard_paused.load(Ordering::Acquire))
.map(|rs| (rs.url.clone(), rs.gen, rs.flags.clone()))
};
if let Some((url, gen, flags)) = reconnect_info {
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
let (new_prod, new_cons) = rb.split();
// Send new consumer to AudioStreamReader (non-blocking; unbounded channel).
let ok = flags.new_cons_tx.lock().unwrap().send(new_cons).is_ok();
if ok {
let new_task = tokio::spawn(radio_download_task(
gen,
state.generation.clone(),
None, // task performs its own fresh GET
state.http_client.clone(),
url,
new_prod,
flags.clone(),
app,
));
if let Some(rs) = state.radio_state.lock().unwrap().as_mut() {
let old = std::mem::replace(&mut rs.task, new_task);
old.abort(); // ensure any lingering old task is gone
rs.flags.is_hard_paused.store(false, Ordering::Release);
rs.flags.is_paused.store(false, Ordering::Release);
}
} else {
eprintln!("[radio] resume: AudioStreamReader gone — skipping reconnect");
}
}
// Resume the rodio Sink (works for both warm and cold resume).
{
let mut cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink {
if sink.is_paused() {
let pos = cur.paused_at.unwrap_or(cur.seek_offset);
sink.play();
cur.seek_offset = pos;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
}
}
}
if let Some(rs) = state.radio_state.lock().unwrap().as_ref() {
rs.flags.is_paused.store(false, Ordering::Release);
}
Ok(())
}
#[tauri::command]
pub fn audio_stop(state: State<'_, AudioEngine>) {
state.generation.fetch_add(1, Ordering::SeqCst);
*state.chained_info.lock().unwrap() = None;
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
drop(state.radio_state.lock().unwrap().take());
let mut cur = state.current.lock().unwrap();
if let Some(sink) = cur.sink.take() {
sink.stop();
}
if let Some(sink) = cur.sink.take() { sink.stop(); }
cur.duration_secs = 0.0;
cur.seek_offset = 0.0;
cur.play_started = None;
cur.paused_at = None;
cur.seek_offset = 0.0;
cur.play_started = None;
cur.paused_at = None;
}
#[tauri::command]
@@ -1868,11 +2249,11 @@ pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result<String, Str
.text().await.map_err(|e| e.to_string())
}
/// Fetches the AutoEQ GraphicEQ profile for a specific headphone from GitHub raw content.
/// Fetches the AutoEQ FixedBandEQ profile for a specific headphone from GitHub raw content.
///
/// Directory layout in the AutoEQ repo:
/// results/{source}/{form}/{name}/{name} GraphicEQ.txt (most sources)
/// results/{source}/{rig} {form}/{name}/{name} GraphicEQ.txt (crinacle — rig-prefixed dir)
/// results/{source}/{form}/{name}/{name} FixedBandEQ.txt (most sources)
/// results/{source}/{rig} {form}/{name}/{name} FixedBandEQ.txt (crinacle — rig-prefixed dir)
///
/// We try the rig-prefixed path first (when rig is present), then fall back to form-only.
#[tauri::command]
@@ -1884,7 +2265,7 @@ pub async fn autoeq_fetch_profile(
state: State<'_, AudioEngine>,
) -> Result<String, String> {
let base = "https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results";
let filename = format!("{} GraphicEQ.txt", name);
let filename = format!("{} FixedBandEQ.txt", name);
let candidates: Vec<String> = if let Some(ref r) = rig {
vec![
@@ -1902,7 +2283,7 @@ pub async fn autoeq_fetch_profile(
}
}
Err(format!("GraphicEQ profile not found for '{}'", name))
Err(format!("FixedBandEQ profile not found for '{}'", name))
}
#[tauri::command]
@@ -1942,10 +2323,9 @@ pub async fn audio_preload(
/// Play a live internet radio stream.
///
/// Unlike `audio_play`, the stream URL is infinite so bytes cannot be
/// downloaded upfront. A `RadioBuffer` bridges the async HTTP download into
/// the synchronous Read interface that symphonia/rodio expect. Emits
/// `audio:playing` with `duration = 0.0` to signal "unknown duration" to JS.
/// Sends `Icy-MetaData: 1` to request inline ICY metadata.
/// Emits `audio:playing` with `duration = 0.0` (sentinel for live stream)
/// and `radio:metadata` whenever the StreamTitle changes.
#[tauri::command]
pub async fn audio_play_radio(
url: String,
@@ -1955,7 +2335,9 @@ pub async fn audio_play_radio(
) -> Result<(), String> {
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
// Cancel pending chain and fading-out sink immediately so audio stops.
// Abort any previous radio task before stopping the sink.
drop(state.radio_state.lock().unwrap().take());
*state.chained_info.lock().unwrap() = None;
{
let mut cur = state.current.lock().unwrap();
@@ -1963,12 +2345,17 @@ pub async fn audio_play_radio(
}
if let Some(old) = state.fading_out_sink.lock().unwrap().take() { old.stop(); }
// Open the HTTP stream.
// ── Open initial HTTP connection ──────────────────────────────────────────
let response = state.http_client
.get(&url)
.header("Icy-MetaData", "0") // opt-out of Shoutcast inline metadata
.send().await
.map_err(|e| { let m = format!("radio: connection failed: {e}"); app.emit("audio:error", &m).ok(); m })?;
.header("Icy-MetaData", "1")
.send()
.await
.map_err(|e| {
let m = format!("radio: connection failed: {e}");
app.emit("audio:error", &m).ok();
m
})?;
if !response.status().is_success() {
let m = format!("radio: HTTP {}", response.status());
@@ -1976,139 +2363,98 @@ pub async fn audio_play_radio(
return Err(m);
}
// Derive a format hint from Content-Type so symphonia probes faster.
let ct = response.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_lowercase();
let fmt_hint: Option<String> = if ct.contains("mpeg") || ct.contains("mp3") {
Some("mp3".into())
} else if ct.contains("aac") || ct.contains("aacp") {
Some("aac".into())
} else if ct.contains("ogg") {
Some("ogg".into())
} else if ct.contains("flac") {
Some("flac".into())
} else {
None
let fmt_hint = content_type_to_hint(
response.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
);
// ── Build 4 MB lock-free SPSC ring buffer ─────────────────────────────────
let rb = HeapRb::<u8>::new(RADIO_BUF_CAPACITY);
let (prod, cons) = rb.split();
let (new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
let flags = Arc::new(RadioSharedFlags {
is_paused: AtomicBool::new(false),
is_hard_paused: AtomicBool::new(false),
new_cons_tx: Mutex::new(new_cons_tx),
});
// ── Spawn download task ───────────────────────────────────────────────────
let task = tokio::spawn(radio_download_task(
gen,
state.generation.clone(),
Some(response),
state.http_client.clone(),
url.clone(),
prod,
flags.clone(),
app.clone(),
));
*state.radio_state.lock().unwrap() = Some(RadioLiveState {
url: url.clone(),
gen,
task,
flags: flags.clone(),
});
// ── Build Symphonia decoder in a blocking thread ──────────────────────────
let reader = AudioStreamReader {
cons,
new_cons_rx: Mutex::new(new_cons_rx),
deadline: std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
gen_arc: state.generation.clone(),
gen,
pos: 0,
};
let (radio_buf, feed) = RadioBuffer::new();
let feed = Arc::new(feed);
// Background task: stream HTTP chunks into RadioBuffer with auto-reconnect.
// Many radio CDNs (Shoutcast/Icecast) close the TCP connection periodically.
// Rather than stopping, we reconnect to the same URL and keep pushing into
// the same RadioBuffer so the decoder recovers with only a brief glitch.
{
let gen_arc = state.generation.clone();
let feed2 = feed.clone();
let http_client2 = state.http_client.clone();
let url2 = url.clone();
tokio::spawn(async move {
let mut bytes_total: u64 = 0;
let mut reconnects: u32 = 0;
// Use the already-open response for the first connection; reconnect as needed.
let mut response_opt: Option<reqwest::Response> = Some(response);
'outer: loop {
if gen_arc.load(Ordering::SeqCst) != gen {
eprintln!("[radio] download: gen mismatch → exit ({bytes_total} bytes, {reconnects} reconnects)");
break 'outer;
}
let resp = match response_opt.take() {
Some(r) => r,
None => {
tokio::time::sleep(Duration::from_millis(500)).await;
if gen_arc.load(Ordering::SeqCst) != gen { break 'outer; }
match http_client2.get(&url2).header("Icy-MetaData", "0").send().await {
Ok(r) if r.status().is_success() => {
reconnects += 1;
eprintln!("[radio] reconnected #{reconnects} ({bytes_total} bytes so far)");
feed2.flush(); // clear stale buffer so decoder gets a clean stream start
r
},
Ok(r) => { eprintln!("[radio] reconnect failed: HTTP {} — giving up", r.status()); break 'outer; },
Err(e) => { eprintln!("[radio] reconnect error: {e} — giving up"); break 'outer; },
}
},
};
let mut stream = resp.bytes_stream();
loop {
if gen_arc.load(Ordering::SeqCst) != gen { break 'outer; }
if feed2.is_full() {
tokio::time::sleep(Duration::from_millis(50)).await;
continue;
}
match stream.next().await {
Some(Ok(chunk)) => {
bytes_total += chunk.len() as u64;
feed2.push(&chunk);
},
Some(Err(e)) => {
eprintln!("[radio] stream error: {e} → reconnecting (attempt {})", reconnects + 1);
break; // triggers outer reconnect
},
None => {
eprintln!("[radio] stream ended cleanly → reconnecting (attempt {})", reconnects + 1);
break;
},
}
}
}
eprintln!("[radio] download task exiting, closing feed");
feed2.close();
});
}
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
// Build symphonia decoder in a blocking thread (RadioBuffer::read blocks).
let hint_clone = fmt_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(radio_buf), fmt_hint.as_deref())
}).await.map_err(|e| e.to_string())??;
SizedDecoder::new_streaming(Box::new(reader), hint_clone.as_deref())
})
.await
.map_err(|e| e.to_string())??;
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
let sample_rate = decoder.sample_rate();
let channels = decoder.channels();
let done_flag = Arc::new(AtomicBool::new(false));
state.samples_played.store(0, Ordering::Relaxed);
// Radio: no gapless trim, no ReplayGain, 5 ms micro-fade to suppress click.
let dyn_src = DynSource::new(decoder.convert_samples::<f32>());
let sample_rate = decoder.sample_rate();
let channels = decoder.channels();
let done_flag = Arc::new(AtomicBool::new(false));
let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0));
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(), state.eq_enabled.clone(), state.eq_pre_gain.clone());
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
state.samples_played.store(0, Ordering::Relaxed);
// Radio: no gapless trim, no ReplayGain, 5 ms fade-in to suppress click.
let dyn_src = DynSource::new(decoder.convert_samples::<f32>());
let eq_src = EqSource::new(dyn_src, state.eq_gains.clone(),
state.eq_enabled.clone(), state.eq_pre_gain.clone());
let fade_in = EqualPowerFadeIn::new(eq_src, Duration::from_millis(5));
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag.clone());
let counting = CountingSource::new(notifying, state.samples_played.clone());
let counting = CountingSource::new(notifying, state.samples_played.clone());
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
let effective_vol = (volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0);
sink.set_volume(effective_vol);
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(counting);
{
let mut cur = state.current.lock().unwrap();
if let Some(old) = cur.sink.take() { old.stop(); }
cur.sink = Some(sink);
cur.duration_secs = 0.0; // sentinel: live stream
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.sink = Some(sink);
cur.duration_secs = 0.0; // sentinel: live stream
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
cur.paused_at = None;
cur.replay_gain_linear = 1.0;
cur.base_volume = volume.clamp(0.0, 1.0);
cur.fadeout_trigger = Some(fadeout_trigger);
cur.fadeout_samples = Some(fadeout_samples);
cur.base_volume = volume.clamp(0.0, 1.0);
cur.fadeout_trigger = Some(fadeout_trigger);
cur.fadeout_samples = Some(fadeout_samples);
}
state.current_sample_rate.store(sample_rate, Ordering::Relaxed);
@@ -2137,7 +2483,7 @@ pub async fn audio_play_radio(
#[tauri::command]
pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) {
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
state.crossfade_secs.store(secs.clamp(0.5, 12.0).to_bits(), Ordering::Relaxed);
state.crossfade_secs.store(secs.clamp(0.1, 12.0).to_bits(), Ordering::Relaxed);
}
#[tauri::command]
+417 -99
View File
@@ -6,10 +6,11 @@ mod discord;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::Ordering;
use tauri::{
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent},
Emitter, Manager,
};
@@ -17,6 +18,10 @@ use tauri::{
/// Prevents on_shortcut() accumulating duplicate handlers across JS reloads (HMR / StrictMode).
type ShortcutMap = Mutex<HashMap<String, String>>;
/// 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>>;
/// Shared handle to OS media controls (MPRIS2 on Linux, Now Playing on macOS, SMTC on Windows).
/// `None` if souvlaki failed to initialize (e.g. no D-Bus session on Linux).
type MprisControls = Mutex<Option<souvlaki::MediaControls>>;
@@ -75,6 +80,197 @@ fn relaunch_after_update(app: tauri::AppHandle) {
app.exit(0);
}
/// Authenticate with Navidrome's own REST API and return a Bearer token.
async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result<String, String> {
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/auth/login", server_url))
.json(&serde_json::json!({ "username": username, "password": password }))
.send()
.await
.map_err(|e| e.to_string())?;
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
data["token"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "Navidrome auth: no token in response".to_string())
}
#[tauri::command]
async fn upload_playlist_cover(
server_url: String,
playlist_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/playlist/{}/image", server_url, playlist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn upload_radio_cover(
server_url: String,
radio_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn upload_artist_image(
server_url: String,
artist_id: String,
username: String,
password: String,
file_bytes: Vec<u8>,
mime_type: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("cover.jpg")
.mime_str(&mime_type)
.map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().part("image", part);
reqwest::Client::new()
.post(format!("{}/api/artist/{}/image", server_url, artist_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.multipart(form)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn delete_radio_cover(
server_url: String,
radio_id: String,
username: String,
password: String,
) -> Result<(), String> {
let token = navidrome_token(&server_url, &username, &password).await?;
let resp = reqwest::Client::new()
.delete(format!("{}/api/radio/{}/image", server_url, radio_id))
.header("X-ND-Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| e.to_string())?;
// 404/503 = no image existed — treat as success
if !resp.status().is_success() && resp.status() != reqwest::StatusCode::NOT_FOUND && resp.status() != reqwest::StatusCode::SERVICE_UNAVAILABLE {
resp.error_for_status().map_err(|e| e.to_string())?;
}
Ok(())
}
const RADIO_PAGE_SIZE: u32 = 25;
/// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView).
#[tauri::command]
async fn search_radio_browser(query: String, offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string();
let resp = client
.get("https://de1.api.radio-browser.info/json/stations/search")
.header("User-Agent", "psysonic/1.0")
.query(&[
("name", query.as_str()),
("hidebroken", "true"),
("limit", limit_s.as_str()),
("offset", offset_s.as_str()),
])
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
}
/// Fetch top-voted stations from radio-browser.info for initial suggestions.
#[tauri::command]
async fn get_top_radio_stations(offset: u32) -> Result<Vec<serde_json::Value>, String> {
let client = reqwest::Client::new();
let limit_s = RADIO_PAGE_SIZE.to_string();
let offset_s = offset.to_string();
let resp = client
.get("https://de1.api.radio-browser.info/json/stations/topvote")
.header("User-Agent", "psysonic/1.0")
.query(&[("limit", limit_s.as_str()), ("offset", offset_s.as_str())])
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
resp.json::<Vec<serde_json::Value>>().await.map_err(|e| e.to_string())
}
/// Fetch arbitrary URL bytes (e.g. radio station favicon) through Rust to bypass CORS.
/// Returns (bytes, content_type).
#[tauri::command]
async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(&url)
.header("User-Agent", "psysonic/1.0")
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("image/jpeg")
.split(';')
.next()
.unwrap_or("image/jpeg")
.trim()
.to_string();
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
Ok((bytes.to_vec(), content_type))
}
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
/// `params` is a list of [key, value] pairs (method must be included).
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
@@ -244,14 +440,24 @@ async fn download_track_offline(
server_id: String,
url: String,
suffix: String,
custom_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<String, String> {
let cache_dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
.join(&server_id);
// Determine base cache directory.
let cache_dir = if let Some(ref cd) = custom_dir {
let base = std::path::PathBuf::from(cd);
// Check that the volume/directory is still accessible.
if !base.exists() {
return Err("VOLUME_NOT_FOUND".to_string());
}
base.join(&server_id)
} else {
app.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
.join(&server_id)
};
tokio::fs::create_dir_all(&cache_dir)
.await
@@ -282,59 +488,203 @@ async fn download_track_offline(
Ok(path_str)
}
/// Returns the total size in bytes of all files in the offline cache directory.
/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir).
#[tauri::command]
async fn get_offline_cache_size(app: tauri::AppHandle) -> u64 {
let offline_dir = match app.path().app_data_dir() {
async fn get_offline_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
fn dir_size(root: std::path::PathBuf) -> u64 {
if !root.exists() { return 0; }
let mut total: u64 = 0;
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
let default_dir = match app.path().app_data_dir() {
Ok(d) => d.join("psysonic-offline"),
Err(_) => return 0,
};
if !offline_dir.exists() {
return 0;
}
let mut total: u64 = 0;
let mut stack = vec![offline_dir];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
let mut total = dir_size(default_dir);
if let Some(cd) = custom_dir {
let custom = std::path::PathBuf::from(cd);
if custom != std::path::PathBuf::from("") {
total += dir_size(custom);
}
}
total
}
/// Removes a cached track from the offline cache directory.
/// Removes a cached track from the offline cache. Accepts the full local path
/// (stored in OfflineTrackMeta) so it works regardless of which directory was used.
/// After deleting the file, empty parent directories up to (but not including)
/// `base_dir` are pruned using `remove_dir` (never `remove_dir_all`).
#[tauri::command]
async fn delete_offline_track(
track_id: String,
server_id: String,
suffix: String,
local_path: String,
base_dir: Option<String>,
app: tauri::AppHandle,
) -> Result<(), String> {
let file_path = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
.join(&server_id)
.join(format!("{}.{}", track_id, suffix));
let file_path = std::path::PathBuf::from(&local_path);
if file_path.exists() {
tokio::fs::remove_file(&file_path)
.await
.map_err(|e| e.to_string())?;
}
// Determine the safe boundary — never delete at or above this directory.
let boundary = if let Some(bd) = base_dir.filter(|s| !s.is_empty()) {
std::path::PathBuf::from(bd)
} else {
app.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("psysonic-offline")
};
// Walk upward, pruning directories that have become empty.
// Stops as soon as a non-empty directory or the boundary is reached.
let mut current = file_path.parent().map(|p| p.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(&boundary) {
break;
}
match std::fs::read_dir(&dir) {
Ok(mut entries) => {
if entries.next().is_some() {
break; // Directory still has contents — stop pruning.
}
if std::fs::remove_dir(&dir).is_err() {
break; // Could not remove (e.g. permissions) — stop.
}
current = dir.parent().map(|p| p.to_path_buf());
}
Err(_) => break,
}
}
Ok(())
}
/// Builds and returns a new system-tray icon with all menu items and event handlers.
/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation).
fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?;
let menu = MenuBuilder::new(app)
.item(&play_pause)
.item(&previous)
.item(&next)
.item(&sep1)
.item(&show_hide)
.item(&sep2)
.item(&quit)
.build()?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Psysonic")
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
"next" => { let _ = app.emit("tray:next", ()); }
"previous" => { let _ = app.emit("tray:previous", ()); }
"show_hide" => {
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
"quit" => { stop_audio_engine(app); app.exit(0); }
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event {
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
})
.build(app)
}
/// Show (`true`) or fully remove (`false`) the system-tray icon.
///
/// The command is strictly idempotent:
/// - `show=true` when the icon is already present → no-op (prevents duplicate icons).
/// - `show=false` when the icon is already absent → no-op.
///
/// For removal, `set_visible(false)` is called explicitly before the handle is
/// dropped because some platforms (Windows notification area, certain Linux DEs)
/// process the OS removal asynchronously — hiding first prevents a brief "ghost"
/// icon from appearing alongside a freshly created one.
#[tauri::command]
fn toggle_tray_icon(
app: tauri::AppHandle,
tray_state: tauri::State<TrayState>,
show: bool,
) -> Result<(), String> {
let mut guard = tray_state.lock().unwrap();
if show {
// Early-return when already shown — never build a second icon.
if guard.is_some() {
return Ok(());
}
*guard = Some(build_tray_icon(&app).map_err(|e| e.to_string())?);
} else if let Some(tray) = guard.take() {
// Hide synchronously before dropping so the OS processes the removal
// before any subsequent show=true call can create a new icon.
let _ = tray.set_visible(false);
// `tray` drops here → frees the OS resource (NIM_DELETE / StatusNotifierItem / NSStatusItem).
}
Ok(())
}
/// Stops the Rust audio engine cleanly (mirrors the logic in `audio_stop`).
/// Called before process exit on macOS to ensure audio stops immediately.
fn stop_audio_engine(app: &tauri::AppHandle) {
let engine = app.state::<audio::AudioEngine>();
engine.generation.fetch_add(1, Ordering::SeqCst);
*engine.chained_info.lock().unwrap() = None;
drop(engine.radio_state.lock().unwrap().take());
let mut cur = engine.current.lock().unwrap();
if let Some(sink) = cur.sink.take() { sink.stop(); }
}
pub fn run() {
let (audio_engine, _audio_thread) = audio::create_engine();
@@ -342,6 +692,7 @@ pub fn run() {
.manage(audio_engine)
.manage(ShortcutMap::default())
.manage(discord::DiscordState::new())
.manage(TrayState::default())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
@@ -359,65 +710,11 @@ pub fn run() {
.setup(|app| {
// ── System tray ───────────────────────────────────────────────
// Always build on startup; the frontend calls toggle_tray_icon(false)
// immediately after load if the user has disabled the tray icon.
{
let play_pause = MenuItemBuilder::with_id("play_pause", "Play / Pause").build(app)?;
let next = MenuItemBuilder::with_id("next", "Next Track").build(app)?;
let previous = MenuItemBuilder::with_id("previous", "Previous Track").build(app)?;
let sep1 = PredefinedMenuItem::separator(app)?;
let show_hide = MenuItemBuilder::with_id("show_hide", "Show / Hide").build(app)?;
let sep2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", "Exit Psysonic").build(app)?;
let menu = MenuBuilder::new(app)
.item(&play_pause)
.item(&previous)
.item(&next)
.item(&sep1)
.item(&show_hide)
.item(&sep2)
.item(&quit)
.build()?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Psysonic")
.on_menu_event(|app, event| match event.id.as_ref() {
"play_pause" => { let _ = app.emit("tray:play-pause", ()); }
"next" => { let _ = app.emit("tray:next", ()); }
"previous" => { let _ = app.emit("tray:previous", ()); }
"show_hide" => {
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
"quit" => { std::process::exit(0); }
_ => {}
})
.on_tray_icon_event(|tray, event| {
// Left-click: toggle window visibility
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event {
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
})
.build(app)?;
let tray = build_tray_icon(app.handle())?;
*app.state::<TrayState>().lock().unwrap() = Some(tray);
}
// ── MPRIS2 / OS media controls via souvlaki ──────────────────
@@ -515,9 +812,22 @@ pub fn run() {
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() == "main" {
// Let JS decide: minimize to tray or exit, based on user setting.
api.prevent_close();
let _ = window.emit("window:close-requested", ());
#[cfg(target_os = "macos")]
{
// On macOS the red close button quits the app entirely.
// Stop the audio engine first so sound cuts immediately.
let app = window.app_handle();
stop_audio_engine(app);
app.exit(0);
}
#[cfg(not(target_os = "macos"))]
{
// Let JS decide: minimize to tray or exit, based on user setting.
let _ = window.emit("window:close-requested", ());
}
}
}
})
@@ -546,10 +856,18 @@ pub fn run() {
discord::discord_update_presence,
discord::discord_clear_presence,
lastfm_request,
upload_playlist_cover,
upload_radio_cover,
upload_artist_image,
delete_radio_cover,
search_radio_browser,
get_top_radio_stations,
fetch_url_bytes,
download_track_offline,
delete_offline_track,
get_offline_cache_size,
relaunch_after_update,
toggle_tray_icon,
])
.run(tauri::generate_context!())
.expect("error while running Psysonic");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.31.0",
"version": "1.33.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
+7
View File
@@ -314,6 +314,13 @@ function TauriEventBridge() {
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
// 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);
useEffect(() => {
invoke('toggle_tray_icon', { show: showTrayIcon }).catch(console.error);
}, [showTrayIcon]);
// Configurable keybindings
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
+123
View File
@@ -1,5 +1,6 @@
import axios from 'axios';
import md5 from 'md5';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
import { version } from '../../package.json';
@@ -96,6 +97,14 @@ export interface InternetRadioStation {
coverArt?: string; // Navidrome v0.61.0+
}
export interface RadioBrowserStation {
stationuuid: string;
name: string;
url: string;
favicon: string;
tags: string;
}
export interface SubsonicPlaylist {
id: string;
name: string;
@@ -105,6 +114,7 @@ export interface SubsonicPlaylist {
changed: string;
owner?: string;
public?: boolean;
comment?: string;
coverArt?: string;
}
@@ -417,6 +427,49 @@ export async function updatePlaylist(id: string, songIds: string[], prevCount =
}
}
export async function updatePlaylistMeta(
id: string,
name: string,
comment: string,
isPublic: boolean,
): Promise<void> {
await api('updatePlaylist.view', { playlistId: id, name, comment, public: isPublic });
}
export async function uploadPlaylistCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_playlist_cover', {
serverUrl: baseUrl,
playlistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function uploadArtistImage(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_artist_image', {
serverUrl: baseUrl,
artistId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deletePlaylist(id: string): Promise<void> {
await api('deletePlaylist.view', { id });
}
@@ -483,3 +536,73 @@ export async function updateInternetRadioStation(
export async function deleteInternetRadioStation(id: string): Promise<void> {
await api('deleteInternetRadioStation.view', { id });
}
export async function uploadRadioCoverArt(id: string, file: File): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
const buffer = await file.arrayBuffer();
const fileBytes = Array.from(new Uint8Array(buffer));
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType: file.type || 'image/jpeg',
});
}
export async function deleteRadioCoverArt(id: string): Promise<void> {
// Navidrome-specific endpoint — handled in Rust to bypass browser CORS restrictions.
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('delete_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
});
}
export async function uploadRadioCoverArtBytes(id: string, fileBytes: number[], mimeType: string): Promise<void> {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
await invoke('upload_radio_cover', {
serverUrl: baseUrl,
radioId: id,
username: server?.username ?? '',
password: server?.password ?? '',
fileBytes,
mimeType,
});
}
function parseRadioBrowserStations(raw: Array<Record<string, string>>): RadioBrowserStation[] {
return raw.map(s => ({
stationuuid: s.stationuuid ?? '',
name: s.name ?? '',
url: s.url ?? '',
favicon: s.favicon ?? '',
tags: s.tags ?? '',
}));
}
export const RADIO_PAGE_SIZE = 25;
export async function searchRadioBrowser(query: string, offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('search_radio_browser', { query, offset });
return parseRadioBrowserStations(raw);
}
export async function getTopRadioStations(offset = 0): Promise<RadioBrowserStation[]> {
const raw = await invoke<Array<Record<string, string>>>('get_top_radio_stations', { offset });
return parseRadioBrowserStations(raw);
}
export async function fetchUrlBytes(url: string): Promise<[number[], string]> {
return invoke<[number[], string]>('fetch_url_bytes', { url });
}
+1 -2
View File
@@ -199,11 +199,10 @@ export default function AlbumHeader({
</div>
<button
className="btn btn-ghost"
className={`btn btn-ghost album-detail-star-btn${isStarred ? ' is-starred' : ''}`}
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
>
<Heart size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
+58 -250
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { SubsonicSong } from '../api/subsonic';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
@@ -46,85 +47,25 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
}
// ── Column configuration ──────────────────────────────────────────────────────
// 'num' → always 60 px fixed, no resize handle
// 'title' → minmax(150px, 1fr) via flex:true, absorbs window-resize changes
// rest → persistent px values from useTracklistColumns hook
const COLUMNS = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true, fixed: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 100, defaultWidth: 220, required: true, fixed: false },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 160, required: false, fixed: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false, fixed: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 100, required: false, fixed: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 60, required: false, fixed: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 80, required: false, fixed: false },
] as const;
const COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'genre', i18nKey: 'trackGenre', minWidth: 60, defaultWidth: 90, required: false },
];
type ColKey = (typeof COLUMNS)[number]['key'];
type ColKey = 'num' | 'title' | 'artist' | 'favorite' | 'rating' | 'duration' | 'format' | 'genre';
const DEFAULT_WIDTHS: Record<ColKey, number> = Object.fromEntries(
COLUMNS.map(c => [c.key, c.defaultWidth])
) as Record<ColKey, number>;
const DEFAULT_VISIBLE = new Set<ColKey>([
'num', 'title', 'artist', 'favorite', 'rating', 'duration', 'format', 'genre',
]);
function loadColPrefs(): { widths: Record<ColKey, number>; visible: Set<ColKey> } {
try {
const raw = localStorage.getItem('psysonic_tracklist_columns');
if (!raw) return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
const parsed = JSON.parse(raw);
const visible = new Set<ColKey>((parsed.visible as ColKey[]) ?? [...DEFAULT_VISIBLE]);
COLUMNS.filter(c => c.required).forEach(c => visible.add(c.key as ColKey));
return {
widths: { ...DEFAULT_WIDTHS, ...(parsed.widths ?? {}) },
visible,
};
} catch {
return { widths: { ...DEFAULT_WIDTHS }, visible: new Set(DEFAULT_VISIBLE) };
}
}
function saveColPrefs(widths: Record<ColKey, number>, visible: Set<ColKey>) {
localStorage.setItem('psysonic_tracklist_columns', JSON.stringify({
widths,
visible: [...visible],
}));
}
/** Scale flexible (non-fixed) visible columns proportionally to fill `targetW` exactly.
* Fixed columns (e.g. 'num') keep their width unchanged.
* Each flexible column is clamped to its minWidth; rounding error is absorbed by 'title'. */
function fitColumnsToWidth(
widths: Record<ColKey, number>,
vCols: readonly { readonly key: string; readonly minWidth: number; readonly fixed: boolean }[],
targetW: number,
gapPx: number
): Record<ColKey, number> {
if (vCols.length === 0 || targetW <= 0) return widths;
const next = { ...widths };
const fixedCols = vCols.filter(c => c.fixed);
const flexCols = vCols.filter(c => !c.fixed);
if (flexCols.length === 0) return next;
const totalGaps = Math.max(0, vCols.length - 1) * gapPx;
const fixedTotal = fixedCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
const available = targetW - totalGaps - fixedTotal;
if (available <= 0) return next;
const currentFlexTotal = flexCols.reduce((s, c) => s + (next[c.key as ColKey] ?? c.minWidth), 0);
if (currentFlexTotal === 0) return next;
const ratio = available / currentFlexTotal;
flexCols.forEach(c => {
const key = c.key as ColKey;
next[key] = Math.max(c.minWidth, Math.round((next[key] ?? c.minWidth) * ratio));
});
// Correct rounding drift in 'title' column
const newFlexTotal = flexCols.reduce((s, c) => s + next[c.key as ColKey], 0);
const diff = available - newFlexTotal;
if (flexCols.some(c => c.key === 'title') && diff !== 0) {
const titleDef = COLUMNS.find(c => c.key === 'title')!;
next['title'] = Math.max(titleDef.minWidth, next['title'] + diff);
}
return next;
}
// Columns where cell content should be centred (both header and rows)
const CENTERED_COLS = new Set<ColKey>(['favorite', 'rating', 'duration']);
// ── Props ─────────────────────────────────────────────────────────────────────
@@ -164,142 +105,12 @@ export default function AlbumTrackList({
const [lastSelectedIdx, setLastSelectedIdx] = useState<number | null>(null);
const [showPlPicker, setShowPlPicker] = useState(false);
// ── Column state ──────────────────────────────────────────────────────────
const [colWidths, setColWidths] = useState<Record<ColKey, number>>(() => loadColPrefs().widths);
const [colVisible, setColVisible] = useState<Set<ColKey>>(() => loadColPrefs().visible);
const [pickerOpen, setPickerOpen] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
const tracklistRef = useRef<HTMLDivElement>(null);
const prevContainerW = useRef(0);
const colVisibleRef = useRef(colVisible);
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
// Stores the user's last intentional column widths + the container W they match.
// ResizeObserver always scales FROM this base — never from intermediate scaled values.
// This prevents drift when the window is shrunk and enlarged again.
const baseWidthsRef = useRef<{ widths: Record<ColKey, number>; containerW: number } | null>(null);
// Tracks current colWidths without a useEffect dependency in callbacks
const colWidthsRef = useRef(colWidths);
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
// On mount: fit saved (or default) widths to current container; establish base.
useLayoutEffect(() => {
const el = tracklistRef.current;
if (!el) return;
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const containerW = el.clientWidth - paddingH;
prevContainerW.current = containerW;
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
setColWidths(prev => {
const fitted = fitColumnsToWidth(prev, vCols, containerW, 12);
baseWidthsRef.current = { widths: fitted, containerW };
return fitted;
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// When the container resizes, scale all columns proportionally FROM the base.
// Using the base (not prev) means shrink → grow always returns to exact original widths.
useEffect(() => {
const el = tracklistRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const newW = el.clientWidth - paddingH;
if (Math.abs(newW - prevContainerW.current) < 2) return;
prevContainerW.current = newW;
const base = baseWidthsRef.current;
if (!base) return;
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
const gapPx = headerEl ? (parseFloat(getComputedStyle(headerEl).columnGap) || 12) : 12;
const vCols = COLUMNS.filter(c => colVisibleRef.current.has(c.key));
// Always scale from base.widths, never from current state → no drift
setColWidths(() => fitColumnsToWidth(base.widths, vCols, newW, gapPx));
});
observer.observe(el);
return () => observer.disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// All visible columns in order
const visibleCols = useMemo(
() => COLUMNS.filter(c => colVisible.has(c.key)),
[colVisible]
);
// Grid template: all fixed px — bidirectional resize works correctly
const gridTemplate = useMemo(
() => visibleCols.map(c => `${colWidths[c.key]}px`).join(' '),
[colWidths, visibleCols]
);
const colStyle = { gridTemplateColumns: gridTemplate };
// ── Bidirectional resize ─────────────────────────────────────────────────
// Dragging the divider between col[colIndex] and col[colIndex+1]:
// → right: colA grows, colB shrinks (clamped to minWidth)
// → left: colA shrinks, colB grows (clamped to minWidth)
// Excel-style resize: only the dragged column changes width.
// Clamped so total never exceeds container width — no overflow, no scrollbar.
const startResize = (e: React.MouseEvent, colIndex: number) => {
e.preventDefault();
e.stopPropagation();
const colA = visibleCols[colIndex];
const defA = COLUMNS.find(c => c.key === colA.key)!;
const startX = e.clientX;
const startW = colWidths[colA.key as ColKey];
const snapshotVisible = colVisible;
// Measure container once at drag start
let maxW = Infinity;
const el = tracklistRef.current;
if (el) {
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const containerW = el.clientWidth - paddingH;
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
const gapPx = headerEl ? (parseFloat(getComputedStyle(headerEl).columnGap) || 0) : 12;
const sumOthers = visibleCols
.filter((_, i) => i !== colIndex)
.reduce((s, c) => s + colWidths[c.key as ColKey], 0);
maxW = Math.max(defA.minWidth, containerW - sumOthers - (visibleCols.length - 1) * gapPx);
}
const onMove = (me: MouseEvent) => {
const newW = Math.min(Math.max(defA.minWidth, startW + me.clientX - startX), maxW);
setColWidths(prev => ({ ...prev, [colA.key]: newW }));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
// Save final state and update base so future window resizes scale from here
const finalWidths = colWidthsRef.current;
baseWidthsRef.current = { widths: { ...finalWidths }, containerW: prevContainerW.current };
saveColPrefs(finalWidths, snapshotVisible);
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
const toggleColumn = (key: ColKey) => {
const def = COLUMNS.find(c => c.key === key)!;
if (def.required) return;
setColVisible(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
saveColPrefs(colWidths, next);
return next;
});
};
// ── Column state (resize, visibility, picker) via shared hook ────────────
const {
colWidths, colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(COLUMNS, 'psysonic_tracklist_columns');
const toggleSelect = (id: string, globalIdx: number, shift: boolean) => {
setSelectedIds(prev => {
@@ -333,17 +144,6 @@ export default function AlbumTrackList({
return () => document.removeEventListener('mousedown', handler);
}, [showPlPicker]);
useEffect(() => {
if (!pickerOpen) return;
const handler = (e: MouseEvent) => {
if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [pickerOpen]);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const discs = new Map<number, SubsonicSong[]>();
songs.forEach(song => {
const disc = song.discNumber ?? 1;
@@ -356,13 +156,13 @@ export default function AlbumTrackList({
const inSelectMode = selectedIds.size > 0;
// ── Header cell renderer ──────────────────────────────────────────────────
const renderHeaderCell = (colDef: (typeof COLUMNS)[number], colIndex: number) => {
const renderHeaderCell = (colDef: ColDef, colIndex: number) => {
const key = colDef.key as ColKey;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = key === 'favorite' || key === 'rating' || key === 'duration';
const isCentered = CENTERED_COLS.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : '';
// 'num' header mirrors the row-cell layout exactly so checkbox + # stay aligned
// num header: checkbox + # label, mirrors row-cell layout exactly
if (key === 'num') {
return (
<div key={key} className="track-num">
@@ -376,21 +176,31 @@ export default function AlbumTrackList({
);
}
// title (1fr): label + divider on RIGHT edge that controls the NEXT px column (drag→shrinks it)
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />
)}
</div>
);
}
// px-width columns: centred or left-aligned label + right-edge divider (except last col)
// direction=1: drag right → this column grows, title (1fr) shrinks
const isResizable = !isLastCol;
return (
<div
key={key}
className={isCentered ? 'col-center' : undefined}
style={{ position: 'relative' }}
>
<span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</span>
{/* Resize handle on all non-fixed columns except the last */}
{!isLastCol && !colDef.fixed && (
<div
className="col-resize-handle"
onMouseDown={e => startResize(e, colIndex)}
/>
<div key={key} data-align={isCentered ? 'center' : 'start'} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{isResizable && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
@@ -442,10 +252,9 @@ export default function AlbumTrackList({
return (
<div key="favorite" className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
className={`btn btn-ghost track-star-btn${starredSongs.has(song.id) ? ' is-starred' : ''}`}
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
@@ -521,7 +330,7 @@ export default function AlbumTrackList({
{/* ── Header ── */}
<div style={{ position: 'relative' }}>
<div className="tracklist-header" style={colStyle}>
<div className="tracklist-header" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))}
</div>
@@ -538,14 +347,13 @@ export default function AlbumTrackList({
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{COLUMNS.filter(c => !c.required).map(c => {
const key = c.key as ColKey;
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : key;
const isOn = colVisible.has(key);
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey as string}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button
key={key}
key={c.key}
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
onClick={() => toggleColumn(key)}
onClick={() => toggleColumn(c.key)}
>
<span className="tracklist-col-picker-check">
{isOn && <Check size={13} />}
@@ -574,7 +382,7 @@ export default function AlbumTrackList({
<div
key={song.id}
className={`track-row track-row-va${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={colStyle}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (inSelectMode) {
+9 -4
View File
@@ -20,7 +20,7 @@ function isNewer(a: string, b: string): boolean {
type State =
| { phase: 'idle' }
| { phase: 'available'; version: string; update: Update | null }
| { phase: 'available'; version: string; update: Update | null; error?: string }
| { phase: 'downloading'; pct: number }
| { phase: 'installing' }
| { phase: 'done' };
@@ -82,9 +82,11 @@ export default function AppUpdater() {
}
});
await invoke('relaunch_after_update');
} catch (e) {
console.error('Update failed', e);
setState({ phase: 'available', version: savedVersion, update });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error('Update failed:', msg);
// Surface the error so the user (and developer) can see what went wrong
setState({ phase: 'available', version: savedVersion, update, error: msg });
}
};
@@ -122,6 +124,9 @@ export default function AppUpdater() {
{state.phase === 'available' && (
<div className="app-updater-actions">
{state.error && (
<div className="app-updater-error">{state.error}</div>
)}
{canInstall && (
<>
<p className="app-updater-hint">{t('common.updaterExperimentalHint')}</p>
+19 -26
View File
@@ -148,7 +148,7 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) {
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const gain = Math.round(pctToGain(pct) / 0.1) * 0.1; // snap to 0.1 dB
const gain = parseFloat((Math.round(pctToGain(pct) / 0.1) * 0.1).toFixed(1)); // snap to 0.1 dB
onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain)));
}, [onChange]);
@@ -189,30 +189,24 @@ interface AutoEqVariant { form: string; rig: string | null; source: string; }
interface AutoEqResult { name: string; source: string; rig: string | null; form: string; }
function parseGraphicEqString(graphicEqStr: string): number[] {
const line = graphicEqStr.replace(/^GraphicEQ:\s*/i, '');
const points: [number, number][] = line
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(s => { const [f, g] = s.split(/\s+/).map(Number); return [f, g] as [number, number]; })
.filter(([f, g]) => !isNaN(f) && !isNaN(g));
/** Parses AutoEQ FixedBandEQ.txt format.
* Expected lines:
* Preamp: -5.5 dB
* Filter 1: ON PK Fc 31 Hz Gain -0.2 dB Q 1.41
* ...
* Returns all 10 band gains as exact floats and the preamp value.
*/
function parseFixedBandEqString(text: string): { gains: number[]; preamp: number } {
const preampMatch = text.match(/Preamp:\s*(-?\d+(?:\.\d+)?)\s*dB/i);
const preamp = preampMatch ? parseFloat(preampMatch[1]) : 0;
if (points.length === 0) return [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
return [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000].map(targetFreq => {
if (targetFreq <= points[0][0]) return points[0][1];
if (targetFreq >= points[points.length - 1][0]) return points[points.length - 1][1];
for (let i = 0; i < points.length - 1; i++) {
if (points[i][0] <= targetFreq && points[i + 1][0] >= targetFreq) {
const lo = points[i], hi = points[i + 1];
if (lo[0] === hi[0]) return lo[1];
const t = (Math.log10(targetFreq) - Math.log10(lo[0])) / (Math.log10(hi[0]) - Math.log10(lo[0]));
return Math.round((lo[1] + t * (hi[1] - lo[1])) / 0.1) * 0.1;
}
}
return 0;
const gains: number[] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const allFilters = [...text.matchAll(/^Filter\s+\d+:\s+ON\s+PK\s+.*?Gain\s+(-?\d+(?:\.\d+)?)\s+dB/gim)];
allFilters.slice(0, 10).forEach((m, i) => {
gains[i] = parseFloat(m[1]);
});
return { gains, preamp };
}
// ─── Main component ───────────────────────────────────────────────────────────
@@ -303,9 +297,8 @@ export default function Equalizer() {
form: result.form,
});
if (!text) throw new Error(t('settings.eqAutoEqFetchError'));
const newGains = parseGraphicEqString(text);
// autoeq.app normalizes gains (preamp baked in) — apply with 0 pre-gain
applyAutoEq(result.name, newGains, 0);
const { gains: newGains, preamp } = parseFixedBandEqString(text);
applyAutoEq(result.name, newGains, preamp);
setAutoEqApplied(result.name);
setAutoEqQuery('');
setAutoEqResults([]);
+121 -122
View File
@@ -1,11 +1,10 @@
import React, { useCallback, useEffect, useState, useRef, memo, useMemo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
ChevronDown, Repeat, Repeat1, Square, Music, Heart
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo, star, unstar } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
@@ -16,45 +15,8 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
function MarqueeTitle({ title }: { title: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [scrollAmount, setScrollAmount] = useState(0);
const measure = useCallback(() => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
// Temporarily make span inline-block to get its natural width
text.style.display = 'inline-block';
const textWidth = text.getBoundingClientRect().width;
text.style.display = '';
const overflow = textWidth - container.clientWidth;
setScrollAmount(overflow > 4 ? Math.ceil(overflow) : 0);
}, []);
useEffect(() => {
measure();
const ro = new ResizeObserver(measure);
if (containerRef.current) ro.observe(containerRef.current);
return () => ro.disconnect();
}, [title, measure]);
return (
<div ref={containerRef} className="fs-title-wrap">
<span
ref={textRef}
className={scrollAmount > 0 ? 'fs-title-marquee' : ''}
style={scrollAmount > 0 ? { '--scroll-amount': `-${scrollAmount}px` } as React.CSSProperties : {}}
>
{title}
</span>
</div>
);
}
// ─── Crossfading blurred background ───────────────────────────────────────────
const FsBg = memo(function FsBg({ url }: { url: string }) {
// ─── Artist portrait — right half, crossfades on track change ─────────────────
const FsPortrait = memo(function FsPortrait({ url }: { url: string }) {
const [layers, setLayers] = useState<Array<{ url: string; id: number; visible: boolean }>>(() =>
url ? [{ url, id: 0, visible: true }] : []
);
@@ -64,8 +26,6 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
if (!url) return;
let cancelled = false;
const id = counterRef.current++;
// Preload the image before starting the crossfade — prevents a blank flash
// between the old and new layer while the browser decodes the image.
const img = new Image();
img.onload = img.onerror = () => {
if (cancelled) return;
@@ -75,29 +35,34 @@ const FsBg = memo(function FsBg({ url }: { url: string }) {
setLayers(prev => prev.map(l => ({ ...l, visible: l.id === id })));
setTimeout(() => {
if (!cancelled) setLayers(prev => prev.filter(l => l.id === id));
}, 800);
}, 1000);
});
};
img.src = url;
return () => { cancelled = true; };
}, [url]);
if (layers.length === 0) return null;
return (
<>
<div className="fs-portrait-wrap" aria-hidden="true">
{layers.map(layer => (
<div
<img
key={layer.id}
className="fs-bg"
style={{ backgroundImage: `url(${layer.url})`, opacity: layer.visible ? 1 : 0 }}
aria-hidden="true"
src={layer.url}
className="fs-portrait"
style={{ opacity: layer.visible ? 1 : 0 }}
decoding="async"
loading="eager"
alt=""
/>
))}
</>
</div>
);
});
// ─── Progress bar (isolated — re-renders every tick) ──────────────────────────
const FsProgress = memo(function FsProgress({ duration }: { duration: number }) {
// ─── Full-width seekbar (isolated — re-renders every tick) ────────────────────
const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
const progress = usePlayerStore(s => s.progress);
const buffered = usePlayerStore(s => s.buffered);
const currentTime = usePlayerStore(s => s.currentTime);
@@ -112,21 +77,22 @@ const FsProgress = memo(function FsProgress({ duration }: { duration: number })
const buf = Math.max(pct, buffered * 100);
return (
<div className="fs-progress-wrap">
<span className="fs-time">{formatTime(currentTime)}</span>
<div className="fs-progress-bar">
<div className="fs-seekbar-wrap">
<div className="fs-seekbar-times">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
<div className="fs-seekbar">
<div className="fs-seekbar-bg" />
<div className="fs-seekbar-buf" style={{ width: `${buf}%` }} />
<div className="fs-seekbar-played" style={{ width: `${pct}%` }} />
<input
type="range" min={0} max={1} step={0.001}
value={progress}
onChange={handleSeek}
style={{
'--pct': `${pct}%`,
'--buf': `${buf}%`,
} as React.CSSProperties}
aria-label="progress"
aria-label="seek"
/>
</div>
<span className="fs-time">{formatTime(duration)}</span>
</div>
);
});
@@ -150,28 +116,40 @@ interface FullscreenPlayerProps {
export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const currentTrack = usePlayerStore(s => s.currentTrack);
const repeatMode = usePlayerStore(s => s.repeatMode);
const next = usePlayerStore(s => s.next);
const previous = usePlayerStore(s => s.previous);
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
const toggleStar = useCallback(async () => {
if (!currentTrack) return;
const nextVal = !isStarred;
setStarredOverride(currentTrack.id, nextVal);
try {
if (nextVal) await star(currentTrack.id, 'song');
else await unstar(currentTrack.id, 'song');
} catch {
setStarredOverride(currentTrack.id, !nextVal);
}
}, [currentTrack, isStarred, setStarredOverride]);
const duration = currentTrack?.duration ?? 0;
// buildCoverArtUrl generates a new salt on every call — must be memoized
// to prevent useCachedUrl from re-fetching on every progress re-render (100 ms).
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '', [currentTrack?.coverArt]);
// No fetchUrl fallback for the background — we only want stable blob URLs
// to avoid a double crossfade (fetchUrl → blobUrl for the same image).
// buildCoverArtUrl generates a new salt on every call — must be memoized.
const coverUrl = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
const coverKey = useMemo(() => currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 500) : '', [currentTrack?.coverArt]);
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
// Fetch artist image for background — fall back to cover art if unavailable
// Artist image → portrait on right. Falls back to cover art.
const [artistBgUrl, setArtistBgUrl] = useState<string>('');
useEffect(() => {
setArtistBgUrl('');
@@ -184,7 +162,7 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return () => { cancelled = true; };
}, [currentTrack?.artistId]);
const bgUrl = artistBgUrl || resolvedCoverUrl;
const portraitUrl = artistBgUrl || resolvedCoverUrl;
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
@@ -192,84 +170,105 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const metaParts = [
currentTrack?.album,
currentTrack?.year?.toString(),
currentTrack?.suffix?.toUpperCase(),
currentTrack?.bitRate ? `${currentTrack.bitRate} kbps` : '',
].filter(Boolean);
return (
<div className="fs-player" role="dialog" aria-modal="true" aria-label={t('player.fullscreen')}>
{/* Layer 1blurred artist image */}
<FsBg url={bgUrl} />
<div className="fs-bg-overlay" aria-hidden="true" />
{/* Layer 0animated dark mesh gradient (real divs = will-change possible) */}
<div className="fs-mesh-bg" aria-hidden="true">
<div className="fs-mesh-blob fs-mesh-blob-a" />
<div className="fs-mesh-blob fs-mesh-blob-b" />
</div>
{/* Layer 1 — artist portrait, right half, object-fit: contain */}
<FsPortrait url={portraitUrl} />
{/* Layer 2 — horizontal scrim: dark left → transparent right */}
<div className="fs-scrim" aria-hidden="true" />
{/* Close */}
<button className="fs-close" onClick={onClose} aria-label={t('player.closeFullscreen')}>
<ChevronDown size={28} />
</button>
{/* Layer 3 — info cluster, bottom-left */}
<div className="fs-cluster">
{/* Center stage — everything vertically + horizontally centered */}
<div className="fs-stage">
<p className="fs-artist">{currentTrack?.artist ?? '—'}</p>
<div className="fs-cover-wrap">
{/* Album art */}
<div className="fs-art-wrap">
{coverUrl ? (
<CachedImage
src={coverUrl}
cacheKey={coverKey}
alt={`${currentTrack?.album} Cover`}
className="fs-cover"
className="fs-art"
/>
) : (
<div className="fs-cover fs-cover-placeholder"><Music size={72} /></div>
<div className="fs-art fs-art-placeholder"><Music size={40} /></div>
)}
</div>
<div className="fs-track-info">
<MarqueeTitle title={currentTrack?.title ?? '—'} />
<p className="fs-album">
{currentTrack?.album ?? ''}
{currentTrack?.year ? ` · ${currentTrack.year}` : ''}
</p>
{(currentTrack?.bitRate || currentTrack?.suffix) && (
<span className="fs-codec">
{[
currentTrack.suffix?.toUpperCase(),
currentTrack.bitRate ? `${currentTrack.bitRate} kbps` : ''
].filter(Boolean).join(' · ')}
</span>
)}
</div>
{/* Artist — massive statement */}
<p className="fs-artist-name">{currentTrack?.artist ?? '—'}</p>
<FsProgress duration={duration} />
{/* Track title — accent, light weight */}
<p className="fs-track-title">{currentTrack?.title ?? '—'}</p>
{/* Metadata row */}
{metaParts.length > 0 && (
<div className="fs-meta">
{metaParts.map((part, i) => (
<React.Fragment key={i}>
{i > 0 && <span className="fs-meta-dot">·</span>}
<span>{part}</span>
</React.Fragment>
))}
</div>
)}
{/* Controls */}
<div className="fs-controls">
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop">
<Square size={14} fill="currentColor" />
<button className="fs-btn fs-btn-sm" onClick={stop} aria-label="Stop" data-tooltip={t('player.stop')}>
<Square size={13} fill="currentColor" />
</button>
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')}>
<SkipBack size={20} />
<button className="fs-btn" onClick={() => previous()} aria-label={t('player.prev')} data-tooltip={t('player.prev')}>
<SkipBack size={19} />
</button>
<FsPlayBtn />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')}>
<SkipForward size={20} />
<button className="fs-btn" onClick={() => next()} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
</button>
<button
className={`fs-btn fs-btn-sm ${repeatMode !== 'off' ? 'active' : ''}`}
className={`fs-btn fs-btn-sm${repeatMode !== 'off' ? ' active' : ''}`}
onClick={toggleRepeat}
aria-label={t('player.repeat')}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
<button
className={`fs-btn fs-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
>
<MicVocal size={14} />
</button>
{currentTrack && (
<button
className={`fs-btn fs-btn-sm fs-btn-heart${isStarred ? ' active' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
</button>
)}
</div>
</div>
{/* Layer 4 — full-width seekbar, bottom edge */}
<FsSeekbar duration={duration} />
</div>
);
}
+6 -5
View File
@@ -61,11 +61,12 @@ export default function PlayerBar() {
const duration = currentTrack?.duration ?? 0;
// Cover art: prefer radio station art, fall back to track art.
// Note: getCoverArt.view needs ra-{id}, not the raw coverArt filename Navidrome returns.
const radioCoverSrc = useMemo(
() => currentRadio?.coverArt ? buildCoverArtUrl(currentRadio.coverArt, 128) : '',
[currentRadio?.coverArt]
() => currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 128) : '',
[currentRadio?.coverArt, currentRadio?.id]
);
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(currentRadio.coverArt, 128) : '';
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 128) : '';
const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]);
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : '';
@@ -140,11 +141,11 @@ export default function PlayerBar() {
</div>
{currentTrack && !isRadio && (
<button
className="player-btn player-btn-sm player-star-btn"
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
onClick={toggleStar}
aria-label={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
data-tooltip={isStarred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
style={{ color: isStarred ? 'var(--accent)' : 'var(--text-muted)', flexShrink: 0 }}
style={{ flexShrink: 0 }}
>
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
</button>
+16 -12
View File
@@ -216,7 +216,10 @@ export default function QueuePanel() {
const queueListRef = useRef<HTMLDivElement>(null);
const asideRef = useRef<HTMLElement>(null);
const { isDragging: isPsyDragging, startDrag } = useDragDrop();
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
const isRadioDrag = isPsyDragging && !!psyPayload && (() => {
try { return JSON.parse(psyPayload.data).type === 'radio'; } catch { return false; }
})();
useEffect(() => {
if (!isPsyDragging) {
@@ -240,6 +243,9 @@ export default function QueuePanel() {
let parsedData: any = null;
try { parsedData = JSON.parse(detail.data); } catch { return; }
// Radio streams are not tracks — reject silently
if (parsedData.type === 'radio') return;
const dropTarget = externalDropTargetRef.current;
externalDropTargetRef.current = null;
setExternalDropTarget(null);
@@ -304,9 +310,9 @@ export default function QueuePanel() {
return (
<aside
ref={asideRef}
className={`queue-panel${isPsyDragging ? ' queue-drop-active' : ''}`}
className={`queue-panel${isPsyDragging && !isRadioDrag ? ' queue-drop-active' : ''}`}
onMouseMove={e => {
if (!isPsyDragging || !queueListRef.current) return;
if (!isPsyDragging || isRadioDrag || !queueListRef.current) return;
const items = queueListRef.current.querySelectorAll<HTMLElement>('[data-queue-idx]');
let found = false;
for (let i = 0; i < items.length; i++) {
@@ -398,13 +404,11 @@ export default function QueuePanel() {
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
className={`queue-current-sub truncate${currentTrack.artistId ? ' is-link' : ''}`}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
{currentTrack.year && (
@@ -469,22 +473,22 @@ export default function QueuePanel() {
<div className="crossfade-popover-label">
<Waves size={11} />
{t('queue.crossfade')}
<span className="crossfade-popover-value">{crossfadeSecs}s</span>
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
</div>
<input
type="range"
min={1}
min={0.1}
max={10}
step={0.5}
step={0.1}
value={crossfadeSecs}
onChange={e => {
setCrossfadeSecs(Number(e.target.value));
setCrossfadeSecs(parseFloat(e.target.value));
setCrossfadeEnabled(true);
}}
className="crossfade-popover-slider"
/>
<div className="crossfade-popover-range">
<span>1s</span><span>10s</span>
<span>0.1s</span><span>10s</span>
</div>
</div>
)}
+1 -1
View File
@@ -24,7 +24,7 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
randomMix: { icon: Shuffle, labelKey: 'sidebar.randomMix', to: '/random-mix', section: 'library' },
favorites: { icon: Heart, labelKey: 'sidebar.favorites', to: '/favorites', section: 'library' },
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
// radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' }, // TODO: unhide when radio is ready
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
};
+3 -29
View File
@@ -164,29 +164,10 @@ export default function ThemePicker({ value, onChange }: Props) {
return (
<button
key={t.id}
className="theme-card-btn"
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
<div className={`theme-card-preview${isActive ? ' is-active' : ''}`}>
<div style={{ background: t.bg, height: '55%' }} />
<div style={{ background: t.card, height: '20%' }} />
<div style={{ background: t.accent, height: '25%' }} />
@@ -208,14 +189,7 @@ export default function ThemePicker({ value, onChange }: Props) {
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
<span className={`theme-card-label${isActive ? ' is-active' : ''}`}>
{t.label}
</span>
</button>
+1164 -35
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -273,7 +273,7 @@ export default function AdvancedSearch() {
)}
</h2>
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }}>
<span />
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
@@ -287,7 +287,7 @@ export default function AdvancedSearch() {
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 90px 65px' }}
onDoubleClick={() => playTrack(track, results.songs.map(songToTrack))}
role="row"
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
+75 -12
View File
@@ -3,10 +3,12 @@ import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { X } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
const PAGE_SIZE = 30;
const CURRENT_YEAR = new Date().getFullYear();
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
@@ -22,13 +24,26 @@ export default function Albums() {
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
const [yearFrom, setYearFrom] = useState('');
const [yearTo, setYearTo] = useState('');
const observerTarget = useRef<HTMLDivElement>(null);
const filtered = selectedGenres.length > 0;
const load = useCallback(async (sortType: SortType, offset: number, append = false) => {
const genreFiltered = selectedGenres.length > 0;
const fromNum = parseInt(yearFrom, 10);
const toNum = parseInt(yearTo, 10);
const yearActive = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1;
const load = useCallback(async (
sortType: SortType,
offset: number,
append = false,
yearFilter?: { from: number; to: number },
) => {
setLoading(true);
try {
const data = await getAlbumList(sortType, PAGE_SIZE, offset);
const extra = yearFilter ? { fromYear: yearFilter.from, toYear: yearFilter.to } : {};
const type = yearFilter ? 'byYear' : sortType;
const data = await getAlbumList(type, PAGE_SIZE, offset, extra);
if (append) setAlbums(prev => [...prev, ...data]);
else setAlbums(data);
setHasMore(data.length === PAGE_SIZE);
@@ -54,16 +69,23 @@ export default function Albums() {
}, []);
useEffect(() => {
if (filtered) loadFiltered(selectedGenres, sort);
else { setPage(0); load(sort, 0); }
}, [sort, filtered, selectedGenres, load, loadFiltered]);
setPage(0);
if (genreFiltered) {
loadFiltered(selectedGenres, sort);
} else if (yearActive) {
load(sort, 0, false, { from: fromNum, to: toNum });
} else {
load(sort, 0);
}
}, [sort, genreFiltered, selectedGenres, yearActive, fromNum, toNum, load, loadFiltered]);
const loadMore = useCallback(() => {
if (loading || !hasMore || filtered) return;
if (loading || !hasMore || genreFiltered) return;
const next = page + 1;
setPage(next);
load(sort, next * PAGE_SIZE, true);
}, [loading, hasMore, page, sort, load, filtered]);
const yf = yearActive ? { from: fromNum, to: toNum } : undefined;
load(sort, next * PAGE_SIZE, true, yf);
}, [loading, hasMore, page, sort, load, genreFiltered, yearActive, fromNum, toNum]);
useEffect(() => {
const observer = new IntersectionObserver(
@@ -74,8 +96,10 @@ export default function Albums() {
return () => observer.disconnect();
}, [loadMore]);
const clearYear = () => { setYearFrom(''); setYearTo(''); };
const sortOptions: { value: SortType; label: string }[] = [
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByName', label: t('albums.sortByName') },
{ value: 'alphabeticalByArtist', label: t('albums.sortByArtist') },
];
@@ -84,7 +108,7 @@ export default function Albums() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('albums.title')}</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{sortOptions.map(o => (
{!yearActive && sortOptions.map(o => (
<button
key={o.value}
className={`btn btn-surface ${sort === o.value ? 'btn-sort-active' : ''}`}
@@ -94,6 +118,45 @@ export default function Albums() {
{o.label}
</button>
))}
{/* Year range filter */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{t('albums.yearFilterLabel')}
</span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearFrom')}
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}></span>
<input
className="input"
type="number"
min={1900}
max={CURRENT_YEAR}
placeholder={t('albums.yearTo')}
value={yearTo}
onChange={e => setYearTo(e.target.value)}
style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
/>
{yearActive && (
<button
className="btn btn-ghost"
onClick={clearYear}
data-tooltip={t('albums.yearFilterClear')}
style={{ padding: '4px 6px' }}
>
<X size={13} />
</button>
)}
</div>
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
</div>
</div>
@@ -107,7 +170,7 @@ export default function Albums() {
<div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
{!filtered && (
{!genreFiltered && (
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loading && hasMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
+2 -2
View File
@@ -388,7 +388,7 @@ export default function ArtistDetail() {
{t('artistDetail.topTracks')}
</h2>
<div className="tracklist" style={{ padding: 0, marginBottom: '2rem' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}>
<div style={{ textAlign: 'center' }}>#</div>
<div>{t('artistDetail.trackTitle')}</div>
<div>{t('artistDetail.trackAlbum')}</div>
@@ -400,7 +400,7 @@ export default function ArtistDetail() {
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '36px minmax(150px, 2fr) minmax(100px, 1fr) 60px' }}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(100px, 1fr) 65px' }}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
playTrack(track, topSongs.map(songToTrack));
+4 -4
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { LayoutGrid, List, Images } from 'lucide-react';
import { LayoutGrid, List, Images, ChevronDown } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from '../components/CachedImage';
@@ -254,9 +254,9 @@ export default function Artists() {
)}
{!loading && hasMore && (
<div style={{ margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-ghost" onClick={loadMore}>
{t('artists.loadMore')}
<div style={{ marginTop: 32, marginBottom: '2rem', display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={loadMore}>
<ChevronDown size={16} /> {t('artists.loadMore')}
</button>
</div>
)}
+270 -49
View File
@@ -1,23 +1,46 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
import CachedImage from '../components/CachedImage';
import {
getStarred, getInternetRadioStations,
SubsonicAlbum, SubsonicArtist, SubsonicSong, InternetRadioStation,
buildCoverArtUrl, coverArtCacheKey,
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { ListPlus, Play, X } from 'lucide-react';
import { Cast, ChevronDown, ChevronLeft, ChevronRight, Check, Heart, ListPlus, Play, Star, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
import { useDragDrop } from '../contexts/DragDropContext';
const FAV_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
{ key: 'remove', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
export default function Favorites() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [radioStations, setRadioStations] = useState<InternetRadioStation[]>([]);
const [loading, setLoading] = useState(true);
const { playTrack, enqueue } = usePlayerStore();
// ── Column resize/visibility (must be before early return) ───────────────
const {
colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns');
const { playTrack, enqueue, playRadio, stop } = usePlayerStore();
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentRadio = usePlayerStore(s => s.currentRadio);
const isPlaying = usePlayerStore(s => s.isPlaying);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
@@ -28,18 +51,42 @@ export default function Favorites() {
setStarredOverride(id, false);
setSongs(prev => prev.filter(s => s.id !== id));
}
function unfavoriteStation(id: string) {
setRadioStations(prev => prev.filter(s => s.id !== id));
try {
const next = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
next.delete(id);
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next]));
} catch { /* ignore */ }
}
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const navigate = useNavigate();
useEffect(() => {
getStarred()
.then(res => {
setAlbums(res.albums);
setArtists(res.artists);
setSongs(res.songs);
setLoading(false);
})
.catch(() => setLoading(false));
const loadAll = async () => {
const [starredResult] = await Promise.allSettled([
getStarred(),
]);
if (starredResult.status === 'fulfilled') {
setAlbums(starredResult.value.albums);
setArtists(starredResult.value.artists);
setSongs(starredResult.value.songs);
}
// Radio favorites: read IDs from localStorage, fetch all stations, filter
try {
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
if (favIds.size > 0) {
const all = await getInternetRadioStations();
setRadioStations(all.filter(s => favIds.has(s.id)));
}
} catch { /* ignore */ }
setLoading(false);
};
loadAll();
}, []);
if (loading) {
@@ -51,7 +98,7 @@ export default function Favorites() {
}
const visibleSongs = songs.filter(s => starredOverrides[s.id] !== false);
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0;
const hasAnyFavorites = albums.length > 0 || artists.length > 0 || visibleSongs.length > 0 || radioStations.length > 0;
return (
<div className="content-body animate-fade-in" style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
@@ -71,6 +118,20 @@ export default function Favorites() {
<AlbumRow title={t('favorites.albums')} albums={albums} />
)}
{radioStations.length > 0 && (
<RadioStationRow
title={t('favorites.stations')}
stations={radioStations}
currentRadio={currentRadio}
isPlaying={isPlaying}
onPlay={s => {
if (currentRadio?.id === s.id && isPlaying) stop();
else playRadio(s);
}}
onUnfavorite={unfavoriteStation}
/>
)}
{visibleSongs.length > 0 && (
<section className="album-row-section">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
@@ -96,13 +157,57 @@ export default function Favorites() {
{t('favorites.enqueueAll')}
</button>
</div>
<div className="tracklist" style={{ padding: 0 }}>
<div className="tracklist-header tracklist-va" style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div />
<div className="tracklist" style={{ padding: 0 }} ref={tracklistRef}>
<div style={{ position: 'relative' }}>
<div className="tracklist-header tracklist-va" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isLastCol = colIndex === visibleCols.length - 1;
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') return <div key="num" className="track-num"><span className="track-num-number">#</span></div>;
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
</div>
);
}
if (key === 'remove') return <div key="remove" />;
const isCentered = key === 'duration';
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{!isLastCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />}
</div>
);
})}
</div>
<div className="tracklist-col-picker" ref={pickerRef}>
<button className="tracklist-col-picker-btn" onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }} data-tooltip={t('albumDetail.columns')}>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{FAV_COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button key={c.key} className={`tracklist-col-picker-item${isOn ? ' active' : ''}`} onClick={() => toggleColumn(c.key)}>
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
{label}
</button>
);
})}
</div>
)}
</div>
</div>
{visibleSongs.map((song, i) => {
const track = songToTrack(song);
@@ -110,7 +215,7 @@ export default function Favorites() {
<div
key={song.id}
className="track-row track-row-va"
style={{ gridTemplateColumns: '40px 1fr 1fr 60px 32px' }}
style={gridStyle}
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
playTrack(track, visibleSongs.map(songToTrack));
@@ -133,34 +238,36 @@ export default function Favorites() {
document.addEventListener('mouseup', onUp);
}}
>
<div className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{i + 1}</span>
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
>{song.artist}</span>
</div>
<div className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<button
className="btn-icon fav-remove-btn"
data-tooltip={t('favorites.removeSong')}
onClick={e => { e.stopPropagation(); removeSong(song.id); }}
aria-label={t('favorites.removeSong')}
>
<X size={14} />
</button>
</div>
{visibleCols.map(colDef => {
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}>
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{i + 1}</span>
</div>
);
case 'title': return <div key="title" className="track-info"><span className="track-title">{song.title}</span></div>;
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}>{song.artist}</span>
</div>
);
case 'duration': return (
<div key="duration" className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</div>
);
case 'remove': return (
<div key="remove" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<button className="btn-icon fav-remove-btn" data-tooltip={t('favorites.removeSong')} onClick={e => { e.stopPropagation(); removeSong(song.id); }} aria-label={t('favorites.removeSong')}>
<X size={14} />
</button>
</div>
);
default: return null;
}
})}
</div>
);
})}
@@ -172,3 +279,117 @@ export default function Favorites() {
</div>
);
}
// ── Radio Station Row ─────────────────────────────────────────────────────────
interface RadioStationRowProps {
title: string;
stations: InternetRadioStation[];
currentRadio: InternetRadioStation | null;
isPlaying: boolean;
onPlay: (s: InternetRadioStation) => void;
onUnfavorite: (id: string) => void;
}
function RadioStationRow({ title, stations, currentRadio, isPlaying, onPlay, onUnfavorite }: RadioStationRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
};
const scroll = (dir: 'left' | 'right') => {
if (!scrollRef.current) return;
scrollRef.current.scrollBy({ left: dir === 'left' ? -scrollRef.current.clientWidth * 0.75 : scrollRef.current.clientWidth * 0.75, behavior: 'smooth' });
};
return (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="album-row-nav">
<button className={`nav-btn${!showLeft ? ' disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
<ChevronLeft size={20} />
</button>
<button className={`nav-btn${!showRight ? ' disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{stations.map(s => (
<RadioFavCard
key={s.id}
station={s}
isActive={currentRadio?.id === s.id}
isPlaying={isPlaying}
onPlay={() => onPlay(s)}
onUnfavorite={() => onUnfavorite(s.id)}
/>
))}
</div>
</div>
</section>
);
}
// ── Radio Favorite Card ───────────────────────────────────────────────────────
interface RadioFavCardProps {
station: InternetRadioStation;
isActive: boolean;
isPlaying: boolean;
onPlay: () => void;
onUnfavorite: () => void;
}
function RadioFavCard({ station: s, isActive, isPlaying, onPlay, onUnfavorite }: RadioFavCardProps) {
const { t } = useTranslation();
return (
<div className={`album-card${isActive ? ' radio-card-active' : ''}`}>
<div className="album-card-cover">
{s.coverArt ? (
<CachedImage
src={buildCoverArtUrl(`ra-${s.id}`, 256)}
cacheKey={coverArtCacheKey(`ra-${s.id}`, 256)}
alt={s.name}
className="album-card-cover-img"
/>
) : (
<div className="album-card-cover-placeholder playlist-card-icon">
<Cast size={48} strokeWidth={1.2} />
</div>
)}
{isActive && isPlaying && (
<div className="radio-live-overlay">
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
)}
<div className="album-card-play-overlay">
<button className="album-card-details-btn" onClick={onPlay}>
{isActive && isPlaying ? <X size={15} /> : <Cast size={14} />}
</button>
</div>
</div>
<div className="album-card-info">
<div className="album-card-title">{s.name}</div>
<div className="album-card-artist" style={{ display: 'flex', alignItems: 'center' }}>
<button
className="radio-favorite-btn active"
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', display: 'flex' }}
onClick={onUnfavorite}
data-tooltip={t('radio.unfavorite')}
>
<Heart size={12} fill="currentColor" />
</button>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+434 -136
View File
@@ -1,9 +1,11 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check } from 'lucide-react';
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import {
getPlaylist, updatePlaylist, search, setRating, star, unstar,
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
search, setRating, star, unstar,
getRandomSongs, SubsonicPlaylist, SubsonicSong,
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
@@ -15,6 +17,7 @@ import { useDragDrop } from '../contexts/DragDropContext';
import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -53,6 +56,20 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number)
);
}
// ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 50, defaultWidth: 65, required: false },
{ key: 'format', i18nKey: 'trackFormat', minWidth: 60, defaultWidth: 90, required: false },
{ key: 'delete', i18nKey: null, minWidth: 36, defaultWidth: 36, required: true },
];
const PL_CENTERED = new Set(['favorite', 'rating', 'duration']);
export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
@@ -78,6 +95,8 @@ export default function PlaylistDetail() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [ratings, setRatings] = useState<Record<string, number>>({});
const [editingMeta, setEditingMeta] = useState(false);
const [customCoverId, setCustomCoverId] = useState<string | null>(null);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
@@ -149,10 +168,20 @@ export default function PlaylistDetail() {
}),
[coverQuad]);
const bgFetchUrl = useMemo(() => buildCoverArtUrl(coverQuad[0] ?? '', 300), [coverQuad]);
const bgCacheKey = useMemo(() => coverArtCacheKey(coverQuad[0] ?? '', 300), [coverQuad]);
const effectiveBgId = customCoverId ?? coverQuad[0] ?? '';
const bgFetchUrl = useMemo(() => buildCoverArtUrl(effectiveBgId, 300), [effectiveBgId]);
const bgCacheKey = useMemo(() => coverArtCacheKey(effectiveBgId, 300), [effectiveBgId]);
const resolvedBgUrl = useCachedUrl(bgFetchUrl, bgCacheKey);
const customCoverFetchUrl = useMemo(
() => customCoverId ? buildCoverArtUrl(customCoverId, 300) : null,
[customCoverId],
);
const customCoverCacheKey = useMemo(
() => customCoverId ? coverArtCacheKey(customCoverId, 300) : null,
[customCoverId],
);
// Song search
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@@ -164,8 +193,14 @@ export default function PlaylistDetail() {
const [suggestions, setSuggestions] = useState<SubsonicSong[]>([]);
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
// ── Column resize/visibility ──────────────────────────────────────────────
const {
colVisible, visibleCols, gridStyle,
startResize, toggleColumn,
pickerOpen, setPickerOpen, pickerRef, tracklistRef,
} = useTracklistColumns(PL_COLUMNS, 'psysonic_playlist_columns');
// DnD
const tracklistRef = useRef<HTMLDivElement>(null);
const [dropTargetIdx, setDropTargetIdx] = useState<{ idx: number; before: boolean } | null>(null);
useEffect(() => {
@@ -180,6 +215,7 @@ export default function PlaylistDetail() {
.then(({ playlist, songs }) => {
setPlaylist(playlist);
setSongs(songs);
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
const init: Record<string, number> = {};
const starred = new Set<string>();
songs.forEach(s => {
@@ -230,6 +266,34 @@ export default function PlaylistDetail() {
setSaving(false);
}, [id, touchPlaylist]);
// ── Meta edit ─────────────────────────────────────────────────
const handleSaveMeta = async (opts: {
name: string; comment: string; isPublic: boolean;
coverFile: File | null; coverRemoved: boolean;
}) => {
if (!id || !playlist) return;
await updatePlaylistMeta(id, opts.name.trim() || playlist.name, opts.comment, opts.isPublic);
setPlaylist(p => p
? { ...p, name: opts.name.trim() || p.name, comment: opts.comment, public: opts.isPublic }
: p
);
if (opts.coverFile) {
try {
await uploadPlaylistCoverArt(id, opts.coverFile);
const { playlist: refreshed } = await getPlaylist(id);
setPlaylist(prev => prev ? { ...prev, coverArt: refreshed.coverArt } : prev);
if (refreshed.coverArt) setCustomCoverId(refreshed.coverArt);
showToast(t('playlists.coverUpdated'));
} catch (err) {
showToast(err instanceof Error ? err.message : t('playlists.coverUpdated'), 3000, 'error');
}
} else if (opts.coverRemoved) {
setCustomCoverId(null);
}
showToast(t('playlists.metaSaved'));
setEditingMeta(false);
};
// ── Remove ────────────────────────────────────────────────────
const removeSong = (idx: number) => {
const prevCount = songs.length;
@@ -390,21 +454,61 @@ export default function PlaylistDetail() {
</button>
<div className="album-detail-hero">
{/* 2×2 cover grid */}
<div className="playlist-cover-grid">
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
{/* Cover — click to open edit modal */}
<div
className="playlist-hero-cover"
onClick={() => setEditingMeta(true)}
>
{customCoverId && customCoverFetchUrl && customCoverCacheKey ? (
<CachedImage
src={customCoverFetchUrl}
cacheKey={customCoverCacheKey}
alt=""
className="playlist-cover-grid"
style={{ objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid">
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
</div>
)}
<div className="playlist-hero-cover-overlay">
<Camera size={28} />
</div>
</div>
<div className="album-detail-meta">
<span className="badge album-detail-badge">{t('playlists.titleBadge')}</span>
<h1 className="album-detail-title">{playlist.name}</h1>
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h1 className="album-detail-title" style={{ marginBottom: 0 }}>{playlist.name}</h1>
<button
className="btn btn-ghost"
onClick={() => setEditingMeta(true)}
data-tooltip={t('playlists.editMeta')}
style={{ padding: '4px 6px', opacity: 0.7, flexShrink: 0 }}
>
<Pencil size={14} />
</button>
</div>
{playlist.comment && (
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>{playlist.comment}</div>
)}
</>
<div className="album-detail-info">
<span>{t('playlists.songs', { n: songs.length })}</span>
{songs.length > 0 && <span>· {totalDurationLabel(songs)}</span>}
{playlist.public !== undefined && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
· {playlist.public
? <><Globe size={11} /> {t('playlists.publicLabel')}</>
: <><Lock size={11} /> {t('playlists.privateLabel')}</>}
</span>
)}
{saving && <Loader2 size={12} className="spin-slow" style={{ display: 'inline', marginLeft: 4 }} />}
</div>
<div className="album-detail-actions">
@@ -545,19 +649,75 @@ export default function PlaylistDetail() {
)}
{/* Header */}
<div className="tracklist-header tracklist-va tracklist-playlist">
<div className="col-center" style={{ cursor: songs.length > 0 ? 'pointer' : undefined }} onClick={songs.length > 0 ? toggleAll : undefined}>
{selectedIds.size > 0
? <span className={`bulk-check${allSelected ? ' checked' : ''}`} />
: '#'}
<div style={{ position: 'relative' }}>
<div className="tracklist-header tracklist-va" style={gridStyle}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isLastCol = colIndex === visibleCols.length - 1;
const isCentered = PL_CENTERED.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') return (
<div key="num" className="track-num">
<span
className={`bulk-check${allSelected ? ' checked' : ''}${selectedIds.size > 0 ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleAll(); }}
style={{ cursor: 'pointer' }}
/>
<span className="track-num-number">#</span>
</div>
);
if (key === 'title') {
const hasNextCol = colIndex + 1 < visibleCols.length;
return (
<div key="title" style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: 'flex-start', paddingLeft: 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{hasNextCol && <div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex + 1, -1)} />}
</div>
);
}
if (key === 'delete') return <div key="delete" />;
return (
<div key={key} style={{ position: 'relative', padding: 0, margin: 0, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', justifyContent: isCentered ? 'center' : 'flex-start', paddingLeft: isCentered ? 0 : 12 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
</div>
{!isLastCol && key !== 'delete' && (
<div className="col-resize-handle" onMouseDown={e => startResize(e, colIndex, 1)} />
)}
</div>
);
})}
</div>
<div className="tracklist-col-picker" ref={pickerRef}>
<button
className="tracklist-col-picker-btn"
onClick={e => { e.stopPropagation(); setPickerOpen(v => !v); }}
data-tooltip={t('albumDetail.columns')}
>
<ChevronDown size={14} />
</button>
{pickerOpen && (
<div className="tracklist-col-picker-menu">
<div className="tracklist-col-picker-label">{t('albumDetail.columns')}</div>
{PL_COLUMNS.filter(c => !c.required).map(c => {
const label = c.i18nKey ? t(`albumDetail.${c.i18nKey}`) : c.key;
const isOn = colVisible.has(c.key);
return (
<button
key={c.key}
className={`tracklist-col-picker-item${isOn ? ' active' : ''}`}
onClick={() => toggleColumn(c.key)}
>
<span className="tracklist-col-picker-check">{isOn && <Check size={13} />}</span>
{label}
</button>
);
})}
</div>
)}
</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
<div className="col-center">{t('albumDetail.trackRating')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
<div />
</div>
{songs.length === 0 && (
@@ -578,6 +738,7 @@ export default function PlaylistDetail() {
<div
data-track-idx={idx}
className={`track-row track-row-va tracklist-playlist${currentTrack?.id === song.id ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}${selectedIds.has(song.id) ? ' bulk-selected' : ''}`}
style={gridStyle}
onMouseEnter={e => handleRowMouseEnter(idx, e)}
onMouseDown={e => handleRowMouseDown(e, idx)}
onClick={e => {
@@ -594,76 +755,49 @@ export default function PlaylistDetail() {
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
{/* # — checkbox in select mode, always-visible play button otherwise */}
{(() => {
{visibleCols.map(colDef => {
const inSelectMode = selectedIds.size > 0;
return (
<div
className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}`}
style={{ cursor: 'pointer' }}
onClick={e => {
e.stopPropagation();
playTrack(tracks[idx], tracks);
}}
>
<span
className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }}
/>
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{idx + 1}</span>
</div>
);
})()}
{/* Title */}
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
{/* Artist */}
<div className="track-artist-cell">
<span
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
>{song.artist}</span>
</div>
{/* Favorite */}
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => handleToggleStar(song, e)}
style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
</button>
</div>
{/* Rating */}
<StarRating value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />
{/* Duration */}
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
{/* Format */}
<div className="track-meta">
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
</div>
{/* Delete */}
<div className="playlist-row-delete-cell">
<button
className="playlist-row-delete-btn"
onClick={e => { e.stopPropagation(); removeSong(idx); }}
data-tooltip={t('playlists.removeSong')}
data-tooltip-pos="left"
>
<Trash2 size={13} />
</button>
</div>
switch (colDef.key) {
case 'num': return (
<div key="num" className={`track-num${currentTrack?.id === song.id ? ' track-num-active' : ''}${currentTrack?.id === song.id && !isPlaying ? ' track-num-paused' : ''}`} style={{ cursor: 'pointer' }} onClick={e => { e.stopPropagation(); playTrack(tracks[idx], tracks); }}>
<span className={`bulk-check${selectedIds.has(song.id) ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`} onClick={e => { e.stopPropagation(); toggleSelect(song.id, idx, e.shiftKey); }} />
{currentTrack?.id === song.id && isPlaying && <span className="track-num-eq"><div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div></span>}
<span className="track-num-play"><Play size={13} fill="currentColor" /></span>
<span className="track-num-number">{idx + 1}</span>
</div>
);
case 'title': return (
<div key="title" className="track-info"><span className="track-title">{song.title}</span></div>
);
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div>
);
case 'favorite': return (
<div key="favorite" className="track-star-cell">
<button className="btn btn-ghost track-star-btn" onClick={e => handleToggleStar(song, e)} style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}>
<Heart size={14} fill={(song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'currentColor' : 'none'} />
</button>
</div>
);
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
</div>
);
case 'delete': return (
<div key="delete" className="playlist-row-delete-cell">
<button className="playlist-row-delete-btn" onClick={e => { e.stopPropagation(); removeSong(idx); }} data-tooltip={t('playlists.removeSong')} data-tooltip-pos="left">
<Trash2 size={13} />
</button>
</div>
);
default: return null;
}
})}
</div>
{isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
<div className="playlist-drop-indicator" />
@@ -673,9 +807,12 @@ export default function PlaylistDetail() {
{/* Total row */}
{songs.length > 0 && (
<div className="tracklist-total tracklist-va tracklist-playlist">
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>
<div className="tracklist-total" style={gridStyle}>
{visibleCols.map(c => {
if (c.key === 'title') return <span key="title" className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>;
if (c.key === 'duration') return <span key="duration" className="tracklist-total-value">{formatDuration(songs.reduce((a, s) => a + (s.duration ?? 0), 0))}</span>;
return <span key={c.key} />;
})}
</div>
)}
</div>
@@ -701,21 +838,24 @@ export default function PlaylistDetail() {
{suggestions.filter(s => !existingIds.has(s.id)).length > 0 && (
<>
<div className="tracklist-header tracklist-va tracklist-playlist" style={{ marginTop: 'var(--space-3)' }}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div />
<div />
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
<div />
<div className="tracklist-header tracklist-va" style={{ ...gridStyle, marginTop: 'var(--space-3)' }}>
{visibleCols.map((colDef, colIndex) => {
const key = colDef.key;
const isCentered = PL_CENTERED.has(key);
const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : '';
if (key === 'num') return <div key="num" className="col-center">#</div>;
if (key === 'title') return <div key="title" style={{ paddingLeft: 12 }}>{label}</div>;
if (key === 'delete') return <div key="delete" />;
if (key === 'favorite' || key === 'rating') return <div key={key} />;
return <div key={key} className={isCentered ? 'col-center' : ''}>{label}</div>;
})}
</div>
{suggestions.filter(s => !existingIds.has(s.id)).map((song, idx) => (
<div
key={song.id}
className={`track-row track-row-va tracklist-playlist${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={gridStyle}
onMouseEnter={() => setHoveredSuggestionId(song.id)}
onMouseLeave={() => setHoveredSuggestionId(null)}
onClick={e => {
@@ -728,42 +868,200 @@ export default function PlaylistDetail() {
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
<div className="track-num" style={{ color: 'var(--text-muted)' }}>
{idx + 1}
</div>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span
className={`track-artist${song.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}
>{song.artist}</span>
</div>
{/* no star/rating for suggestions */}
<div />
<div />
<div className="track-duration">{formatDuration(song.duration ?? 0)}</div>
<div className="track-meta">
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
</div>
<div className="playlist-row-delete-cell">
<button
className="playlist-row-delete-btn"
style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }}
onClick={e => { e.stopPropagation(); addSong(song); }}
data-tooltip={t('playlists.addSong')}
data-tooltip-pos="left"
>
<Plus size={13} />
</button>
</div>
{visibleCols.map(colDef => {
switch (colDef.key) {
case 'num': return <div key="num" className="track-num" style={{ color: 'var(--text-muted)' }}>{idx + 1}</div>;
case 'title': return <div key="title" className="track-info"><span className="track-title">{song.title}</span></div>;
case 'artist': return (
<div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div>
);
case 'favorite': return <div key="favorite" />;
case 'rating': return <div key="rating" />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return (
<div key="format" className="track-meta">
{(song.suffix || song.bitRate) && <span className="track-codec">{codecLabel(song)}</span>}
</div>
);
case 'delete': return (
<div key="delete" className="playlist-row-delete-cell">
<button className="playlist-row-delete-btn" style={{ color: hoveredSuggestionId === song.id ? 'var(--accent)' : undefined }} onClick={e => { e.stopPropagation(); addSong(song); }} data-tooltip={t('playlists.addSong')} data-tooltip-pos="left">
<Plus size={13} />
</button>
</div>
);
default: return null;
}
})}
</div>
))}
</>
)}
</div>
{editingMeta && playlist && (
<PlaylistEditModal
playlist={playlist}
customCoverId={customCoverId}
customCoverFetchUrl={customCoverFetchUrl ?? null}
customCoverCacheKey={customCoverCacheKey ?? null}
coverQuadUrls={coverQuadUrls}
onClose={() => setEditingMeta(false)}
onSave={handleSaveMeta}
/>
)}
</div>
);
}
// ── Playlist Edit Modal ───────────────────────────────────────────────────────
interface EditModalProps {
playlist: SubsonicPlaylist;
customCoverId: string | null;
customCoverFetchUrl: string | null;
customCoverCacheKey: string | null;
coverQuadUrls: ({ src: string; cacheKey: string } | null)[];
onClose: () => void;
onSave: (opts: { name: string; comment: string; isPublic: boolean; coverFile: File | null; coverRemoved: boolean }) => Promise<void>;
}
function PlaylistEditModal({
playlist, customCoverId, customCoverFetchUrl, customCoverCacheKey,
coverQuadUrls, onClose, onSave,
}: EditModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(playlist.name);
const [comment, setComment] = useState(playlist.comment ?? '');
const [isPublic, setIsPublic] = useState(playlist.public ?? false);
const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [coverRemoved, setCoverRemoved] = useState(false);
const [saving, setSaving] = useState(false);
const coverInputRef = useRef<HTMLInputElement>(null);
const hasExistingCover = !coverRemoved && (coverPreview || customCoverId);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setCoverFile(file);
setCoverRemoved(false);
const reader = new FileReader();
reader.onload = ev => setCoverPreview(ev.target?.result as string);
reader.readAsDataURL(file);
};
const handleRemoveCover = (e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(null);
setCoverPreview(null);
setCoverRemoved(true);
};
const handleSave = async () => {
setSaving(true);
try {
await onSave({ name, comment, isPublic, coverFile, coverRemoved });
} finally {
setSaving(false);
}
};
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
return (
<div className="modal-overlay" onClick={handleOverlayClick}>
<div className="modal-content playlist-edit-modal" onClick={e => e.stopPropagation()}>
<button className="btn btn-ghost modal-close" onClick={onClose} style={{ top: 16, right: 16 }}>
<X size={18} />
</button>
<h2 className="modal-title" style={{ fontSize: 22 }}>{t('playlists.editMeta')}</h2>
<div className="playlist-edit-body">
{/* Left: cover */}
<div
className="playlist-edit-cover-wrap"
onClick={() => coverInputRef.current?.click()}
>
{coverPreview ? (
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : !coverRemoved && customCoverFetchUrl && customCoverCacheKey ? (
<CachedImage
src={customCoverFetchUrl}
cacheKey={customCoverCacheKey}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<div className="playlist-cover-grid" style={{ width: '100%', height: '100%' }}>
{coverQuadUrls.map((entry, i) =>
entry
? <CachedImage key={i} className="playlist-cover-cell" src={entry.src} cacheKey={entry.cacheKey} alt="" />
: <div key={i} className="playlist-cover-cell playlist-cover-cell--empty" />
)}
</div>
)}
<div className="playlist-edit-cover-overlay">
<div className="playlist-edit-cover-menu">
<button
className="playlist-edit-cover-menu-item"
onClick={e => { e.stopPropagation(); coverInputRef.current?.click(); }}
>
<Camera size={14} />
{t('playlists.changeCoverLabel')}
</button>
{hasExistingCover && (
<button
className="playlist-edit-cover-menu-item playlist-edit-cover-menu-item--danger"
onClick={handleRemoveCover}
>
{t('playlists.removeCover')}
</button>
)}
</div>
</div>
<input ref={coverInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFileChange} />
</div>
{/* Right: fields */}
<div className="playlist-edit-fields">
<input
className="input playlist-edit-name-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('playlists.editNamePlaceholder')}
autoFocus
/>
<textarea
className="input playlist-edit-desc-input"
value={comment}
onChange={e => setComment(e.target.value)}
placeholder={t('playlists.editCommentPlaceholder')}
/>
</div>
</div>
<div className="playlist-edit-footer">
<label className="toggle-label" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
<label className="toggle-switch" style={{ marginBottom: 0 }}>
<input type="checkbox" checked={isPublic} onChange={e => setIsPublic(e.target.checked)} />
<span className="toggle-track" />
</label>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('playlists.editPublic')}</span>
</label>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? <Loader2 size={14} className="spin-slow" /> : null}
{t('playlists.editSave')}
</button>
</div>
</div>
</div>
);
}
+4 -4
View File
@@ -325,7 +325,7 @@ export default function RandomMix() {
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}><div className="spinner" /></div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}>
<div></div>
<div>{t('randomMix.trackTitle')}</div>
<div>{t('randomMix.trackArtist')}</div>
@@ -344,7 +344,7 @@ export default function RandomMix() {
<div
key={song.id}
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 70px 60px' }}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 70px 65px' }}
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
role="row"
onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
@@ -414,7 +414,7 @@ export default function RandomMix() {
</div>
) : (
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}>
<div></div>
<div>{t('randomMix.trackTitle')}</div>
<div>{t('randomMix.trackArtist')}</div>
@@ -443,7 +443,7 @@ export default function RandomMix() {
<div
key={song.id}
className={`track-row${isCurrentTrack ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '60px minmax(80px, 1.5fr) minmax(60px, 1fr) minmax(60px, 1fr) 120px 70px 60px' }}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 120px 70px 65px' }}
onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; playTrack(track, queueSongs); }}
role="row"
onContextMenu={e => {
+2 -2
View File
@@ -69,7 +69,7 @@ export default function SearchResults() {
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('search.songs')}</h2>
</div>
<div className="tracklist" style={{ padding: 0 }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}>
<div className="tracklist-header" style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 100px 65px' }}>
<div />
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
@@ -81,7 +81,7 @@ export default function SearchResults() {
<div
key={song.id}
className={`track-row${currentTrack?.id === song.id ? ' active' : ''}`}
style={{ gridTemplateColumns: '36px minmax(100px, 2fr) minmax(80px, 1.2fr) minmax(80px, 1.2fr) 100px 60px' }}
style={{ gridTemplateColumns: '60px minmax(150px, 1fr) minmax(80px, 1fr) minmax(80px, 1fr) 100px 65px' }}
onDoubleClick={() => playSong(song, results.songs)}
role="row"
onMouseDown={e => {
+351 -150
View File
@@ -5,8 +5,10 @@ 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
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download
} from 'lucide-react';
import { exportBackup, importBackup } from '../utils/backup';
import { showToast } from '../utils/toast';
import { invoke } from '@tauri-apps/api/core';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
@@ -73,6 +75,13 @@ const CONTRIBUTORS = [
'Human-readable audio error messages (PR #44)',
],
},
{
github: 'zz5zz',
since: '1.32.0',
contributions: [
'Norwegian (Bokmål) translation (PR #101)',
],
},
] as const;
const SPECIAL_THANKS = [
@@ -82,7 +91,7 @@ const SPECIAL_THANKS = [
},
] as const;
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
type Tab = 'general' | 'server' | 'audio' | 'storage' | 'appearance' | 'input' | 'system';
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
const { t } = useTranslation();
@@ -162,7 +171,7 @@ export default function Settings() {
const { state: routeState } = useLocation();
const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'server');
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'general');
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState(false);
const [newGenre, setNewGenre] = useState('');
@@ -182,9 +191,9 @@ export default function Settings() {
}, [auth.lastfmSessionKey, auth.lastfmUsername]);
useEffect(() => {
if (activeTab !== 'library') return;
if (activeTab !== 'storage') return;
getImageCacheSize().then(setImageCacheBytes);
invoke<number>('get_offline_cache_size').then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0));
}, [activeTab]);
const handleClearCache = useCallback(async () => {
@@ -193,7 +202,7 @@ export default function Settings() {
await clearAllOffline(serverId);
const [imgBytes, offBytes] = await Promise.all([
getImageCacheSize(),
invoke<number>('get_offline_cache_size').catch(() => 0),
invoke<number>('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).catch(() => 0),
]);
setImageCacheBytes(imgBytes);
setOfflineCacheBytes(offBytes);
@@ -299,6 +308,13 @@ export default function Settings() {
navigate('/login');
};
const pickOfflineDir = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') });
if (selected && typeof selected === 'string') {
auth.setOfflineDownloadDir(selected);
}
};
const pickDownloadFolder = async () => {
const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') });
if (selected && typeof selected === 'string') {
@@ -307,12 +323,13 @@ export default function Settings() {
};
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
{ id: 'playback', label: t('settings.tabPlayback'), icon: <Play size={15} /> },
{ id: 'library', label: t('settings.tabLibrary'), icon: <Shuffle size={15} /> },
{ id: 'shortcuts', label: t('settings.tabShortcuts'), icon: <Keyboard size={15} /> },
{ id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
{ id: 'general', label: t('settings.tabGeneral'), icon: <AppWindow size={15} /> },
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
{ id: 'audio', label: t('settings.tabAudio'), icon: <Music2 size={15} /> },
{ id: 'storage', label: t('settings.tabStorage'), icon: <HardDrive size={15} /> },
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
{ id: 'input', label: t('settings.tabInput'), icon: <Keyboard size={15} /> },
{ id: 'system', label: t('settings.tabSystem'), icon: <Info size={15} /> },
];
return (
@@ -333,8 +350,8 @@ export default function Settings() {
))}
</nav>
{/* ── Playback ─────────────────────────────────────────────────────────── */}
{activeTab === 'playback' && (
{/* ── Audio ────────────────────────────────────────────────────────────── */}
{activeTab === 'audio' && (
<>
{/* Equalizer */}
<section className="settings-section">
@@ -407,16 +424,16 @@ export default function Settings() {
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
min={1}
min={0.1}
max={10}
step={0.5}
step={0.1}
value={auth.crossfadeSecs}
onChange={e => auth.setCrossfadeSecs(Number(e.target.value))}
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
style={{ width: 120 }}
id="crossfade-secs-slider"
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 28 }}>
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs })}
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
</span>
</div>
)}
@@ -440,6 +457,41 @@ export default function Settings() {
</label>
</div>
<div className="divider" />
{/* Preload mode */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.preloadMode')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadModeDesc')}</div>
</div>
</div>
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{(['balanced', 'early', 'custom'] as const).map(mode => (
<button
key={mode}
className={`btn ${auth.preloadMode === mode ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '3px 12px' }}
onClick={() => auth.setPreloadMode(mode)}
>
{t(`settings.preload${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any)}
</button>
))}
</div>
{auth.preloadMode === 'custom' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="range"
min={5} max={120} step={5}
value={auth.preloadCustomSeconds}
onChange={e => auth.setPreloadCustomSeconds(parseInt(e.target.value))}
style={{ width: 120 }}
/>
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
{t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })}
</span>
</div>
)}
</div>
</section>
@@ -447,63 +499,70 @@ export default function Settings() {
</>
)}
{/* ── Library ──────────────────────────────────────────────────────────── */}
{activeTab === 'library' && (
{/* ── General ──────────────────────────────────────────────────────────── */}
{activeTab === 'general' && (
<>
{/* Cache */}
{/* App behaviour */}
<section className="settings-section">
<div className="settings-section-header">
<AppWindow size={18} />
<h2>{t('settings.behavior')}</h2>
</div>
<div className="settings-card">
<div style={{ fontWeight: 500, marginBottom: 4 }}>{t('settings.cacheTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 10, lineHeight: 1.5 }}>
{t('settings.cacheDesc')}
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
<span style={{ marginLeft: 6, color: 'var(--text-secondary)' }}>
{t('settings.cacheUsed', {
images: imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…',
offline: offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…',
})}
</span>
)}
</div>
<div className="settings-toggle-row" style={{ marginBottom: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{auth.maxCacheMb} MB</span>
<input
type="range"
min={100}
max={5000}
step={100}
value={auth.maxCacheMb}
onChange={e => auth.setMaxCacheMb(Number(e.target.value))}
style={{ width: 120 }}
id="cache-size-slider"
/>
</div>
{showClearConfirm ? (
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-primary"
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
onClick={handleClearCache}
disabled={clearing}
>
{t('settings.cacheClearConfirm')}
</button>
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
{t('settings.cacheClearCancel')}
</button>
</div>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showTrayIcon')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showTrayIconDesc')}</div>
</div>
) : (
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
</button>
)}
<label className="toggle-switch" aria-label={t('settings.showTrayIcon')}>
<input type="checkbox" checked={auth.showTrayIcon} onChange={e => auth.setShowTrayIcon(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div>
</section>
@@ -595,6 +654,143 @@ export default function Settings() {
</>
)}
{/* ── Storage & Downloads ───────────────────────────────────────────────── */}
{activeTab === 'storage' && (
<>
{/* Offline Library (In-App) — includes cache settings */}
<section className="settings-section">
<div className="settings-section-header">
<Download size={18} />
<h2>{t('settings.offlineDirTitle')}</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
{t('settings.offlineDirDesc')}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="input"
type="text"
readOnly
value={auth.offlineDownloadDir || t('settings.offlineDirDefault')}
style={{ flex: 1, fontSize: 13, color: auth.offlineDownloadDir ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
/>
{auth.offlineDownloadDir && (
<button
className="btn btn-ghost"
onClick={() => auth.setOfflineDownloadDir('')}
data-tooltip={t('settings.offlineDirClear')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
<X size={16} />
</button>
)}
<button className="btn btn-surface" onClick={pickOfflineDir} style={{ flexShrink: 0 }} id="settings-offline-dir-btn">
<FolderOpen size={16} /> {t('settings.offlineDirChange')}
</button>
</div>
{auth.offlineDownloadDir && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
{t('settings.offlineDirHint')}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', margin: '16px 0' }} />
{(imageCacheBytes !== null || offlineCacheBytes !== null) && (
<div style={{ fontSize: 12, marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedImages')}</span>
{imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'}
</div>
<div style={{ color: 'var(--text-secondary)' }}>
<span style={{ color: 'var(--text-muted)', marginRight: 4 }}>{t('settings.cacheUsedOffline')}</span>
{offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'}
</div>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.cacheMaxLabel')}</span>
<input
className="input"
type="number"
min={100}
max={50000}
step={100}
value={auth.maxCacheMb}
onChange={e => {
const v = Number(e.target.value);
if (v >= 100) auth.setMaxCacheMb(v);
}}
style={{ width: 80, padding: '4px 8px', fontSize: 13 }}
id="cache-size-input"
/>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>MB</span>
</div>
{showClearConfirm ? (
<div style={{ background: 'color-mix(in srgb, var(--color-danger, #e53935) 10%, transparent)', borderRadius: 'var(--radius-sm)', padding: '10px 14px', fontSize: 13, lineHeight: 1.5 }}>
<div style={{ marginBottom: 8, color: 'var(--text-primary)' }}>{t('settings.cacheClearWarning')}</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-primary"
style={{ background: 'var(--color-danger, #e53935)', fontSize: 13 }}
onClick={handleClearCache}
disabled={clearing}
>
{t('settings.cacheClearConfirm')}
</button>
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(false)} disabled={clearing}>
{t('settings.cacheClearCancel')}
</button>
</div>
</div>
) : (
<button className="btn btn-ghost" style={{ fontSize: 13 }} onClick={() => setShowClearConfirm(true)}>
<Trash2 size={14} /> {t('settings.cacheClearBtn')}
</button>
)}
</div>
</section>
{/* ZIP Export & Archiving */}
<section className="settings-section">
<div className="settings-section-header">
<FolderOpen size={18} />
<h2>{t('settings.downloadsTitle')}</h2>
</div>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
{t('settings.downloadsFolderDesc')}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="input"
type="text"
readOnly
value={auth.downloadFolder || t('settings.downloadsDefault')}
style={{ flex: 1, fontSize: 13, color: auth.downloadFolder ? 'var(--text-primary)' : 'var(--text-muted)', cursor: 'default' }}
/>
{auth.downloadFolder && (
<button
className="btn btn-ghost"
onClick={() => auth.setDownloadFolder('')}
aria-label={t('settings.clearFolder')}
data-tooltip={t('settings.clearFolder')}
style={{ color: 'var(--text-muted)', flexShrink: 0 }}
>
<X size={16} />
</button>
)}
<button className="btn btn-surface" onClick={pickDownloadFolder} style={{ flexShrink: 0 }} id="settings-download-folder-btn">
<FolderOpen size={16} /> {t('settings.pickFolder')}
</button>
</div>
</div>
</section>
</>
)}
{/* ── Appearance ───────────────────────────────────────────────────────── */}
{activeTab === 'appearance' && (
<>
@@ -614,6 +810,7 @@ export default function Settings() {
{ value: 'fr', label: t('settings.languageFr') },
{ value: 'de', label: t('settings.languageDe') },
{ value: 'zh', label: t('settings.languageZh') },
{ value: 'nb', label: t('settings.languageNb') },
]}
/>
</div>
@@ -668,13 +865,13 @@ export default function Settings() {
</>
)}
{/* ── Shortcuts ────────────────────────────────────────────────────────── */}
{activeTab === 'shortcuts' && (
{/* ── Input ────────────────────────────────────────────────────────────── */}
{activeTab === 'input' && (
<>
<section className="settings-section">
<div className="settings-section-header">
<Keyboard size={18} />
<h2>{t('settings.tabShortcuts')}</h2>
<h2>{t('settings.tabInput')}</h2>
</div>
<div className="settings-card">
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
@@ -993,89 +1190,13 @@ export default function Settings() {
</div>
</section>
{/* Downloads + Tray */}
<section className="settings-section">
<div className="settings-section-header">
<AppWindow size={18} />
<h2>{t('settings.behavior')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.downloadsTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, wordBreak: 'break-all' }}>
{auth.downloadFolder || t('settings.downloadsDefault')}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{auth.downloadFolder && (
<button
className="btn btn-ghost"
onClick={() => auth.setDownloadFolder('')}
aria-label={t('settings.clearFolder')}
data-tooltip={t('settings.clearFolder')}
style={{ color: 'var(--text-muted)' }}
>
<X size={16} />
</button>
)}
<button className="btn btn-surface" onClick={pickDownloadFolder} id="settings-download-folder-btn">
<FolderOpen size={16} /> {t('settings.pickFolder')}
</button>
</div>
</div>
</div>
</section>
</>
)}
{/* ── About ────────────────────────────────────────────────────────────── */}
{activeTab === 'about' && (
{/* ── System ───────────────────────────────────────────────────────────── */}
{activeTab === 'system' && (
<>
<BackupSection />
<section className="settings-section">
<div className="settings-section-header">
<Info size={18} />
@@ -1417,6 +1538,86 @@ function SidebarCustomizer() {
);
}
function BackupSection() {
const { t } = useTranslation();
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const handleExport = async () => {
setExporting(true);
try {
const path = await exportBackup();
if (path) showToast(t('settings.backupSuccess'), 3000, 'info');
} catch (e) {
console.error('Export failed', e);
showToast(t('settings.backupImportError'), 4000, 'error');
} finally {
setExporting(false);
}
};
const handleImport = async () => {
if (!window.confirm(t('settings.backupImportConfirm'))) return;
setImporting(true);
try {
await importBackup();
// importBackup reloads the page — this toast will briefly show before reload
showToast(t('settings.backupImportSuccess'), 3000, 'info');
} catch (e) {
console.error('Import failed', e);
showToast(t('settings.backupImportError'), 4000, 'error');
setImporting(false);
}
};
return (
<section className="settings-section">
<div className="settings-section-header">
<HardDrive size={18} />
<h2>{t('settings.backupTitle')}</h2>
</div>
{/* Export */}
<div className="settings-card" style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
<div>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupExport')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupExportDesc')}</div>
</div>
<button
className="btn btn-primary"
onClick={handleExport}
disabled={exporting}
style={{ flexShrink: 0 }}
>
<Upload size={14} />
{exporting ? '…' : t('settings.backupExport')}
</button>
</div>
</div>
{/* Import */}
<div className="settings-card">
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '1rem' }}>
<div>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('settings.backupImport')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>{t('settings.backupImportDesc')}</div>
</div>
<button
className="btn btn-surface"
onClick={handleImport}
disabled={importing}
style={{ flexShrink: 0 }}
>
<Download size={14} />
{importing ? '…' : t('settings.backupImport')}
</button>
</div>
</div>
</section>
);
}
function ChangelogSection() {
const { t } = useTranslation();
const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate);
+151 -7
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { getAlbumList, getArtists, getGenres, SubsonicAlbum } from '../api/subsonic';
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';
@@ -14,6 +14,13 @@ function relativeTime(timestamp: number, t: (key: string, opts?: any) => string)
return t('statistics.lfmDaysAgo', { n: Math.floor(diff / 86400) });
}
function formatPlaytime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h.toLocaleString()}h ${m}m`;
return `${m}m`;
}
const PERIODS: { key: LastfmPeriod; label: string }[] = [
{ key: '7day', label: 'lfmPeriod7day' },
{ key: '1month', label: 'lfmPeriod1month' },
@@ -32,8 +39,14 @@ export default function Statistics() {
const [artistCount, setArtistCount] = useState<number | null>(null);
const [totalSongs, setTotalSongs] = useState<number | null>(null);
const [totalAlbums, setTotalAlbums] = useState<number | null>(null);
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [loading, setLoading] = useState(true);
const [totalPlaytime, setTotalPlaytime] = useState<number | null>(null);
const [playtimeCapped, setPlaytimeCapped] = useState(false);
const [formatData, setFormatData] = useState<{ format: string; count: number }[] | null>(null);
const [formatSampleSize, setFormatSampleSize] = useState(0);
const [lfmPeriod, setLfmPeriod] = useState<LastfmPeriod>('1month');
const [lfmTopArtists, setLfmTopArtists] = useState<LastfmTopArtist[]>([]);
const [lfmTopAlbums, setLfmTopAlbums] = useState<LastfmTopAlbum[]>([]);
@@ -54,12 +67,62 @@ export default function Statistics() {
setFrequent(fr);
setHighest(hi);
setArtistCount(a.length);
setTotalSongs(g.reduce((acc: number, genre: any) => acc + genre.songCount, 0));
setTotalAlbums(g.reduce((acc: number, genre: any) => acc + genre.albumCount, 0));
setTotalSongs(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.songCount, 0));
setTotalAlbums(g.reduce((acc: number, genre: SubsonicGenre) => acc + genre.albumCount, 0));
const sorted = [...g].sort((a, b) => b.songCount - a.songCount);
setGenres(sorted);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
// Background fetch: total playtime (paginate getAlbumList up to 10 pages of 500)
useEffect(() => {
let cancelled = false;
(async () => {
let total = 0;
let offset = 0;
const pageSize = 500;
const maxPages = 10;
let capped = false;
for (let page = 0; page < maxPages; page++) {
try {
const albums = await getAlbumList('newest', pageSize, offset);
if (cancelled) return;
for (const a of albums) total += (a.duration ?? 0);
if (albums.length < pageSize) break;
if (page === maxPages - 1) capped = true;
offset += pageSize;
} catch {
break;
}
}
if (!cancelled) {
setTotalPlaytime(total);
setPlaytimeCapped(capped);
}
})();
return () => { cancelled = true; };
}, []);
// Background fetch: format distribution (sample of 500 random songs)
useEffect(() => {
let cancelled = false;
getRandomSongs(500).then(songs => {
if (cancelled) return;
const counts: Record<string, number> = {};
for (const song of songs) {
const fmt = song.suffix?.toUpperCase() ?? 'Unknown';
counts[fmt] = (counts[fmt] ?? 0) + 1;
}
const sorted = Object.entries(counts)
.map(([format, count]) => ({ format, count }))
.sort((a, b) => b.count - a.count);
setFormatData(sorted);
setFormatSampleSize(songs.length);
}).catch(() => {});
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
setLfmRecentLoading(true);
@@ -97,12 +160,20 @@ export default function Statistics() {
}
};
const playtimeDisplay = totalPlaytime === null
? t('statistics.computing')
: (playtimeCapped ? '≥ ' : '') + formatPlaytime(totalPlaytime);
const stats = [
{ label: t('statistics.statArtists'), value: artistCount },
{ label: t('statistics.statAlbums'), value: totalAlbums },
{ label: t('statistics.statSongs'), value: totalSongs },
{ label: t('statistics.statArtists'), value: artistCount?.toLocaleString() ?? '—' },
{ label: t('statistics.statAlbums'), value: totalAlbums?.toLocaleString() ?? '—' },
{ label: t('statistics.statSongs'), value: totalSongs?.toLocaleString() ?? '—' },
{ label: t('statistics.statPlaytime'), value: playtimeDisplay },
];
const topGenres = genres.slice(0, 10);
const maxGenreSongs = topGenres[0]?.songCount ?? 1;
return (
<div className="content-body animate-fade-in">
<h1 className="page-title">{t('statistics.title')}</h1>
@@ -115,12 +186,85 @@ export default function Statistics() {
<div className="stats-overview">
{stats.map(s => (
<div key={s.label} className="stats-card">
<span className="stats-card-value">{s.value?.toLocaleString() ?? '—'}</span>
<span className="stats-card-value">{s.value}</span>
<span className="stats-card-label">{s.label}</span>
</div>
))}
</div>
{/* Genre Insights + Format Distribution */}
{(topGenres.length > 0 || formatData) && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '0.5rem' }}>
{topGenres.length > 0 && (
<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.genreInsights')}
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{topGenres.map(g => (
<div key={g.value}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '70%' }}>
{g.value}
</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', flexShrink: 0, marginLeft: '0.5rem' }}>
{g.songCount.toLocaleString()}
</span>
</div>
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${(g.songCount / maxGenreSongs) * 100}%`,
background: 'var(--accent)',
opacity: 0.7,
borderRadius: '2px',
transition: 'width 0.4s ease',
}} />
</div>
</div>
))}
</div>
</div>
)}
{formatData && (
<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: '0.25rem' }}>
{t('statistics.formatDistribution')}
</h3>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{t('statistics.formatSample', { n: formatSampleSize.toLocaleString() })}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{formatData.map(f => {
const pct = formatSampleSize > 0 ? Math.round((f.count / formatSampleSize) * 100) : 0;
return (
<div key={f.format}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '0.2rem' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 600, fontFamily: 'monospace' }}>{f.format}</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>{pct}%</span>
</div>
<div style={{ height: '4px', borderRadius: '2px', background: 'var(--glass-border)', overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${pct}%`,
background: 'var(--accent)',
opacity: 0.6,
borderRadius: '2px',
transition: 'width 0.4s ease',
}} />
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
{recent.length > 0 && (
<AlbumRow title={t('statistics.recentlyPlayed')} albums={recent} />
)}
+16
View File
@@ -26,6 +26,7 @@ interface AuthState {
scrobblingEnabled: boolean;
maxCacheMb: number;
downloadFolder: string;
offlineDownloadDir: string;
excludeAudiobooks: boolean;
customGenreBlacklist: string[];
replayGainEnabled: boolean;
@@ -33,8 +34,11 @@ interface AuthState {
crossfadeEnabled: boolean;
crossfadeSecs: number;
gaplessEnabled: boolean;
preloadMode: 'balanced' | 'early' | 'custom';
preloadCustomSeconds: number;
infiniteQueueEnabled: boolean;
showArtistImages: boolean;
showTrayIcon: boolean;
minimizeToTray: boolean;
discordRichPresence: boolean;
nowPlayingEnabled: boolean;
@@ -62,6 +66,7 @@ interface AuthState {
setScrobblingEnabled: (v: boolean) => void;
setMaxCacheMb: (v: number) => void;
setDownloadFolder: (v: string) => void;
setOfflineDownloadDir: (v: string) => void;
setExcludeAudiobooks: (v: boolean) => void;
setCustomGenreBlacklist: (v: string[]) => void;
setReplayGainEnabled: (v: boolean) => void;
@@ -69,8 +74,11 @@ interface AuthState {
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setGaplessEnabled: (v: boolean) => void;
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => void;
setPreloadCustomSeconds: (v: number) => void;
setInfiniteQueueEnabled: (v: boolean) => void;
setShowArtistImages: (v: boolean) => void;
setShowTrayIcon: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setDiscordRichPresence: (v: boolean) => void;
setNowPlayingEnabled: (v: boolean) => void;
@@ -99,6 +107,7 @@ export const useAuthStore = create<AuthState>()(
scrobblingEnabled: true,
maxCacheMb: 500,
downloadFolder: '',
offlineDownloadDir: '',
excludeAudiobooks: false,
customGenreBlacklist: [],
replayGainEnabled: false,
@@ -106,8 +115,11 @@ export const useAuthStore = create<AuthState>()(
crossfadeEnabled: false,
crossfadeSecs: 3,
gaplessEnabled: false,
preloadMode: 'balanced',
preloadCustomSeconds: 30,
infiniteQueueEnabled: false,
showArtistImages: false,
showTrayIcon: true,
minimizeToTray: false,
discordRichPresence: false,
nowPlayingEnabled: false,
@@ -162,6 +174,7 @@ export const useAuthStore = create<AuthState>()(
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
setDownloadFolder: (v) => set({ downloadFolder: v }),
setOfflineDownloadDir: (v) => set({ offlineDownloadDir: v }),
setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }),
setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }),
setReplayGainEnabled: (v) => {
@@ -175,8 +188,11 @@ export const useAuthStore = create<AuthState>()(
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setPreloadMode: (v: 'balanced' | 'early' | 'custom') => set({ preloadMode: v }),
setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }),
setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }),
setShowArtistImages: (v) => set({ showArtistImages: v }),
setShowTrayIcon: (v) => set({ showTrayIcon: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }),
+11 -4
View File
@@ -3,6 +3,8 @@ import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl, getArtist, getAlbum } from '../api/subsonic';
import type { SubsonicSong } from '../api/subsonic';
import { useAuthStore } from './authStore';
import { showToast } from '../utils/toast';
export interface OfflineTrackMeta {
id: string;
@@ -156,6 +158,7 @@ export const useOfflineStore = create<OfflineState>()(
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', {
@@ -163,6 +166,7 @@ export const useOfflineStore = create<OfflineState>()(
serverId,
url,
suffix,
customDir,
});
set(state => ({
@@ -195,7 +199,11 @@ export const useOfflineStore = create<OfflineState>()(
: j,
),
}));
} catch {
} catch (err) {
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
if (msg === 'VOLUME_NOT_FOUND') {
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
}
set(state => ({
jobs: state.jobs.map(j =>
j.trackId === song.id && j.albumId === albumId
@@ -263,9 +271,8 @@ export const useOfflineStore = create<OfflineState>()(
const meta = get().tracks[`${serverId}:${trackId}`];
if (!meta) return;
await invoke('delete_offline_track', {
trackId,
serverId,
suffix: meta.suffix,
localPath: meta.localPath,
baseDir: useAuthStore.getState().offlineDownloadDir || null,
}).catch(() => {});
}),
);
+96 -8
View File
@@ -158,6 +158,53 @@ let seekTarget: number | null = null;
// to the Rust backend before it has finished the previous one.
let togglePlayLock = false;
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
// Internet radio streams are played via a native <audio> element instead of
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
// codec support (MP3, AAC, HE-AAC, OGG) and stable ICY stream handling for
// free, without touching the regular playback pipeline at all.
const radioAudio = new Audio();
radioAudio.preload = 'none';
let radioStopping = false;
// Pending reconnect timer for stalled streams — null when no reconnect is scheduled.
let radioReconnectTimer: ReturnType<typeof setTimeout> | null = null;
function clearRadioReconnectTimer() {
if (radioReconnectTimer) { clearTimeout(radioReconnectTimer); radioReconnectTimer = null; }
}
radioAudio.addEventListener('ended', () => {
// Stream disconnected unexpectedly — clear radio state.
clearRadioReconnectTimer();
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
});
radioAudio.addEventListener('error', () => {
clearRadioReconnectTimer();
if (radioStopping) { radioStopping = false; return; }
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
showToast('Radio stream error', 3000, 'error');
});
// Stalled: stream stopped delivering data — try to reconnect after 4 s.
radioAudio.addEventListener('stalled', () => {
if (radioReconnectTimer) return; // already scheduled
radioReconnectTimer = setTimeout(() => {
radioReconnectTimer = null;
if (!usePlayerStore.getState().currentRadio) return;
// Re-assign src to force a fresh connection, then resume playback.
const src = radioAudio.src;
radioAudio.src = src;
radioAudio.play().catch(console.error);
}, 4000);
});
// Waiting: browser is rebuffering — normal for live streams, no action needed.
radioAudio.addEventListener('waiting', () => {
console.debug('[psysonic] radio: buffering');
});
// Suspend: browser paused loading (sufficient buffer) — cancel any stale reconnect.
radioAudio.addEventListener('suspend', () => {
clearRadioReconnectTimer();
});
// Timestamp of the last gapless auto-advance (from audio:track_switched).
// Used to suppress ghost-commands from stale IPC arriving after the switch.
let lastGaplessSwitchTime = 0;
@@ -217,9 +264,15 @@ function handleAudioProgress(current_time: number, duration: number) {
}
}
// Pre-buffer / pre-chain next track when 30 s remain.
const { gaplessEnabled } = useAuthStore.getState();
if (dur - current_time < 30 && dur - current_time > 0) {
// Pre-buffer / pre-chain next track based on preload mode.
const { gaplessEnabled, preloadMode, preloadCustomSeconds } = useAuthStore.getState();
const remaining = dur - current_time;
const shouldPreload = preloadMode === 'early'
? current_time >= 5
: preloadMode === 'custom'
? remaining < preloadCustomSeconds && remaining > 0
: remaining < 30 && remaining > 0; // balanced (default)
if (shouldPreload) {
const { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
const nextTrack = repeatMode === 'one'
@@ -579,7 +632,14 @@ export const usePlayerStore = create<PlayerState>()(
// ── stop ────────────────────────────────────────────────────────────────
stop: () => {
invoke('audio_stop').catch(console.error);
if (get().currentRadio) {
clearRadioReconnectTimer();
radioStopping = true;
radioAudio.pause();
radioAudio.src = '';
} else {
invoke('audio_stop').catch(console.error);
}
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null });
@@ -590,10 +650,17 @@ export const usePlayerStore = create<PlayerState>()(
const { volume } = get();
++playGeneration;
isAudioPaused = false;
clearRadioReconnectTimer();
gaplessPreloadingId = null;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
invoke('audio_play_radio', { url: station.streamUrl, volume }).catch((err: unknown) => {
console.error('[psysonic] audio_play_radio failed:', err);
// Stop Rust engine in case a regular track was playing.
invoke('audio_stop').catch(() => {});
// Play via HTML5 audio — browser handles reconnects, codec negotiation, buffering.
radioAudio.src = station.streamUrl;
radioAudio.volume = volume;
radioAudio.play().catch((err: unknown) => {
console.error('[psysonic] radio HTML5 play failed:', err);
showToast('Radio stream error', 3000, 'error');
set({ isPlaying: false, currentRadio: null });
});
set({
@@ -622,13 +689,24 @@ export const usePlayerStore = create<PlayerState>()(
gaplessPreloadingId = null; // new track — allow fresh preload for next
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
// If a radio stream is active, stop it before the new track starts so
// the PlayerBar clears radio mode immediately and the stream is released.
if (get().currentRadio) {
clearRadioReconnectTimer();
radioStopping = true;
radioAudio.pause();
radioAudio.src = '';
}
const state = get();
const newQueue = queue ?? state.queue;
const idx = newQueue.findIndex(t => t.id === track.id);
// Set state immediately so the UI updates before the download completes.
// currentRadio: null ensures the PlayerBar switches out of radio mode right away.
set({
currentTrack: track,
currentRadio: null,
queue: newQueue,
queueIndex: idx >= 0 ? idx : 0,
progress: 0,
@@ -680,12 +758,21 @@ export const usePlayerStore = create<PlayerState>()(
// ── pause / resume / togglePlay ──────────────────────────────────────────
pause: () => {
invoke('audio_pause').catch(console.error);
isAudioPaused = true;
if (get().currentRadio) {
radioAudio.pause();
} else {
invoke('audio_pause').catch(console.error);
isAudioPaused = true;
}
set({ isPlaying: false });
},
resume: () => {
if (get().currentRadio) {
radioAudio.play().catch(console.error);
set({ isPlaying: true });
return;
}
const { currentTrack, queue, currentTime } = get();
if (!currentTrack) return;
@@ -912,6 +999,7 @@ export const usePlayerStore = create<PlayerState>()(
setVolume: (v) => {
const clamped = Math.max(0, Math.min(1, v));
invoke('audio_set_volume', { volume: clamped }).catch(console.error);
radioAudio.volume = clamped;
set({ volume: clamped });
},
+627 -203
View File
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -311,6 +311,15 @@
font-style: italic;
margin: 0 0 2px;
}
.app-updater-error {
font-size: 10px;
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
border-radius: 4px;
padding: 4px 6px;
margin-bottom: 4px;
word-break: break-all;
}
.app-updater-btn-primary {
display: inline-flex;
align-items: center;
@@ -726,6 +735,13 @@
/* Star + Last.fm heart — visually separated from track title */
.player-star-btn {
margin: var(--space-1);
color: var(--text-muted);
transition: color var(--transition-fast);
}
.player-star-btn:hover,
.player-star-btn.is-starred {
color: var(--color-star-active, var(--accent));
}
.player-btn-primary {
@@ -1016,7 +1032,7 @@
}
.queue-round-btn:hover:not(:disabled) {
background: var(--border);
color: var(--text-primary);
color: var(--accent);
}
.queue-round-btn:disabled {
opacity: 0.35;
@@ -1084,6 +1100,14 @@
.queue-current-sub {
font-size: 12px;
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.queue-current-sub.is-link {
cursor: pointer;
}
.queue-current-sub.is-link:hover {
color: var(--accent);
}
.queue-current-tech {
@@ -1159,6 +1183,12 @@
.queue-item-artist {
font-size: 11px;
color: var(--text-muted);
transition: color var(--transition-fast);
}
.queue-item:hover .queue-item-artist,
.queue-item.context-active .queue-item-artist {
color: var(--text-secondary);
}
.queue-item-duration {
+73
View File
@@ -0,0 +1,73 @@
import { save, open as openDialog } from '@tauri-apps/plugin-dialog';
import { writeFile, readTextFile } from '@tauri-apps/plugin-fs';
import { version as appVersion } from '../../package.json';
const BACKUP_VERSION = 1;
const BACKUP_KEYS = [
'psysonic-auth',
'psysonic_theme',
'psysonic_font',
'psysonic_language',
'psysonic_keybindings',
'psysonic_sidebar',
'psysonic-eq',
'psysonic_global_shortcuts',
'psysonic-player',
];
export async function exportBackup(): Promise<string | null> {
const stores: Record<string, unknown> = {};
for (const key of BACKUP_KEYS) {
const val = localStorage.getItem(key);
if (val !== null) {
try {
stores[key] = JSON.parse(val);
} catch {
stores[key] = val;
}
}
}
const manifest = {
version: BACKUP_VERSION,
app_version: appVersion,
created_at: new Date().toISOString(),
stores,
};
const today = new Date().toISOString().slice(0, 10);
const path = await save({
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }],
defaultPath: `psysonic-backup-${today}.psybkp`,
});
if (!path) return null;
const content = JSON.stringify(manifest, null, 2);
await writeFile(path, new TextEncoder().encode(content));
return path;
}
export async function importBackup(): Promise<void> {
const path = await openDialog({
filters: [{ name: 'Psysonic Backup', extensions: ['psybkp'] }],
multiple: false,
title: 'Import Psysonic Backup',
});
if (!path || typeof path !== 'string') return;
const raw = await readTextFile(path);
const manifest = JSON.parse(raw);
if (typeof manifest.version !== 'number' || !manifest.stores || typeof manifest.stores !== 'object') {
throw new Error('invalid_backup');
}
for (const [key, value] of Object.entries(manifest.stores)) {
localStorage.setItem(key, JSON.stringify(value));
}
window.location.reload();
}
+20
View File
@@ -171,6 +171,26 @@ export async function getImageCacheSize(): Promise<number> {
}
}
/** Removes a single cache entry from both in-memory and IndexedDB caches. */
export async function invalidateCacheKey(cacheKey: string): Promise<void> {
const existing = objectUrlCache.get(cacheKey);
if (existing) {
URL.revokeObjectURL(existing);
objectUrlCache.delete(cacheKey);
}
try {
const database = await openDB();
await new Promise<void>(resolve => {
const tx = database.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(cacheKey);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch {
// Ignore
}
}
/** Clears all entries from IndexedDB and revokes all in-memory object URLs. */
export async function clearImageCache(): Promise<void> {
for (const url of objectUrlCache.values()) {
+179
View File
@@ -0,0 +1,179 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
export interface ColDef {
readonly key: string;
readonly i18nKey?: string | null;
readonly minWidth: number;
readonly defaultWidth: number;
readonly required: boolean;
/** If true the column uses minmax(minWidth, 1fr) instead of a fixed px width. */
readonly flex?: boolean;
}
function loadPrefs(
storageKey: string,
columns: readonly ColDef[],
): { widths: Record<string, number>; visible: Set<string> } {
const defaultWidths: Record<string, number> = Object.fromEntries(
columns.map(c => [c.key, c.defaultWidth]),
);
const defaultVisible = new Set<string>(columns.map(c => c.key));
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return { widths: defaultWidths, visible: defaultVisible };
const parsed = JSON.parse(raw) as { widths?: Record<string, number>; visible?: string[] };
const visible = new Set<string>(parsed.visible ?? [...defaultVisible]);
columns.filter(c => c.required).forEach(c => visible.add(c.key));
return {
widths: { ...defaultWidths, ...(parsed.widths ?? {}) },
visible,
};
} catch {
return { widths: defaultWidths, visible: defaultVisible };
}
}
function savePrefs(storageKey: string, widths: Record<string, number>, visible: Set<string>) {
localStorage.setItem(storageKey, JSON.stringify({ widths, visible: [...visible] }));
}
export function useTracklistColumns(columns: readonly ColDef[], storageKey: string) {
const [colWidths, setColWidths] = useState<Record<string, number>>(
() => loadPrefs(storageKey, columns).widths,
);
const [colVisible, setColVisible] = useState<Set<string>>(
() => loadPrefs(storageKey, columns).visible,
);
const [pickerOpen, setPickerOpen] = useState(false);
const tracklistRef = useRef<HTMLDivElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
// Refs to avoid stale closures in drag/save handlers
const colWidthsRef = useRef(colWidths);
const colVisibleRef = useRef(colVisible);
useEffect(() => { colWidthsRef.current = colWidths; }, [colWidths]);
useEffect(() => { colVisibleRef.current = colVisible; }, [colVisible]);
const visibleCols = useMemo(
() => columns.filter(c => colVisible.has(c.key)),
[columns, colVisible],
);
const gridTemplate = useMemo(
() =>
visibleCols
.map(c => (c.flex ? `minmax(${c.minWidth}px, 1fr)` : `${colWidths[c.key]}px`))
.join(' '),
[visibleCols, colWidths],
);
// Minimum total width so the grid never squishes below its current column sizes.
// When .tracklist is narrower, overflow-x: auto triggers a scrollbar.
// Formula (box-sizing: border-box): colSum + gaps + left/right padding (12px each = 24px)
const gridMinWidth = useMemo(() => {
const gapPx = 12; // --space-3
const boxPaddingH = 24; // var(--space-3) * 2
const colSum = visibleCols.reduce<number>(
(s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]),
0,
);
const gaps = Math.max(0, visibleCols.length - 1) * gapPx;
return colSum + gaps + boxPaddingH;
}, [visibleCols, colWidths]);
const gridStyle = useMemo(
() => ({ gridTemplateColumns: gridTemplate, minWidth: `${gridMinWidth}px` }),
[gridTemplate, gridMinWidth],
);
// Excel-style column resize:
// direction = 1 → right-edge handle: drag right → column grows, 1fr title shrinks
// direction = -1 → left-edge handle : drag right → next px col shrinks, 1fr title grows
const startResize = useCallback(
(e: React.MouseEvent, colIndex: number, direction: 1 | -1 = 1) => {
e.preventDefault();
e.stopPropagation();
const visCols = visibleCols; // stable for the drag duration
const colDef = visCols[colIndex];
const colKey = colDef.key;
const colMin = columns.find(c => c.key === colKey)!.minWidth;
const startX = e.clientX;
const startW = colWidths[colKey];
let maxW = Infinity;
const el = tracklistRef.current;
if (el) {
const style = getComputedStyle(el);
const paddingH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const containerW = el.clientWidth - paddingH;
const headerEl = el.querySelector('.tracklist-header') as HTMLElement | null;
const gapPx = headerEl
? parseFloat(getComputedStyle(headerEl).columnGap) || 12
: 12;
const totalGaps = (visCols.length - 1) * gapPx;
const otherFixed = visCols
.filter((_, i) => i !== colIndex)
.reduce<number>((s, c) => s + (c.flex ? c.minWidth : colWidths[c.key]), 0);
maxW = Math.max(colMin, containerW - totalGaps - otherFixed);
}
const onMove = (me: MouseEvent) => {
const delta = me.clientX - startX;
const newW = Math.min(Math.max(colMin, startW + direction * delta), maxW);
setColWidths(prev => ({ ...prev, [colKey]: newW }));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
savePrefs(storageKey, colWidthsRef.current, colVisibleRef.current);
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[columns, visibleCols, colWidths, storageKey],
);
const toggleColumn = useCallback(
(key: string) => {
const def = columns.find(c => c.key === key)!;
if (def.required) return;
setColVisible(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
savePrefs(storageKey, colWidthsRef.current, next);
return next;
});
},
[columns, storageKey],
);
useEffect(() => {
if (!pickerOpen) return;
const handler = (e: MouseEvent) => {
if (!pickerRef.current?.contains(e.target as Node)) setPickerOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [pickerOpen]);
return {
colWidths,
colVisible,
visibleCols,
gridStyle,
startResize,
toggleColumn,
pickerOpen,
setPickerOpen,
pickerRef,
tracklistRef,
};
}