From c873880a26816fe79a08ef67933c8df999544e9d Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sat, 4 Apr 2026 02:04:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20v1.31.0=20=E2=80=94=20AutoEQ,=20resizab?= =?UTF-8?q?le=20tracklist=20columns,=20Discord=20Listening=20type,=20layou?= =?UTF-8?q?t=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 35 +++ package-lock.json | 32 +- package.json | 3 +- packages/aur/PKGBUILD | 2 +- src-tauri/Cargo.lock | 3 +- src-tauri/Cargo.toml | 3 +- src-tauri/src/audio.rs | 455 +++++++++++++++++++++++++++- src-tauri/src/discord.rs | 10 +- src-tauri/src/lib.rs | 3 + src-tauri/tauri.conf.json | 2 +- src/App.tsx | 26 +- src/api/subsonic.ts | 41 +++ src/components/AlbumCard.tsx | 6 +- src/components/AlbumHeader.tsx | 6 +- src/components/AlbumTrackList.tsx | 477 +++++++++++++++++++++++++----- src/components/Equalizer.tsx | 196 +++++++++++- src/components/PlayerBar.tsx | 80 +++-- src/components/Sidebar.tsx | 3 +- src/i18n.ts | 145 +++++++++ src/pages/AdvancedSearch.tsx | 8 +- src/pages/ArtistDetail.tsx | 10 +- src/pages/Favorites.tsx | 12 +- src/pages/PlaylistDetail.tsx | 39 ++- src/pages/RandomMix.tsx | 24 +- src/pages/Settings.tsx | 15 +- src/store/eqStore.ts | 32 +- src/store/playerStore.ts | 38 ++- src/store/sidebarStore.ts | 13 +- src/styles/components.css | 382 +++++++++++++++++++++--- src/styles/layout.css | 16 +- src/styles/theme.css | 2 + 31 files changed, 1895 insertions(+), 224 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fe29f04..56cae316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ 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.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. ☀️ + +### Added + +- **AutoEQ — 10-Band Parametric Equalizer**: Full parametric EQ with 10 adjustable bands, bypass toggle, and pre-gain control. AutoEQ presets are loaded directly from the AutoEQ GitHub repository — search for your headphone model and apply a community-measured correction curve with one click. +- **Internet Radio — infrastructure** *(work in progress, not yet released)*: The full backend for Internet Radio playback is in place — a dedicated Rust `RadioBuffer` streaming pipeline in the audio engine, Subsonic API integration (`getInternetRadioStations`, create/update/delete), and a `playRadio` action in the player store. The UI page exists but the feature is **not yet accessible** from the sidebar — it will be enabled once the experience is polished. +- **Tracklist columns — resizable & configurable** *(experimental)*: Album tracklist columns can now be resized by dragging the dividers between header cells, similar to a spreadsheet. A column visibility picker (chevron button at the top right) lets you show or hide individual columns. The `#` column is fixed-width. Column widths and visibility are persisted in localStorage. The feature works but is still being refined. +- **Genre column in album tracklist**: Albums that have genre tags per track now show a Genre column in the tracklist. +- **Sidebar auto-migration**: New sidebar items (e.g. Internet Radio) are automatically appended to existing persisted sidebar configurations on first launch — no more missing entries after updates. + +### Changed + +- **Discord Rich Presence**: Activity type is now `Listening` instead of the default `Playing`. The artist field no longer has the "by " prefix — Discord's layout makes the context clear without it. Album name is shown as a tooltip on the cover icon. +- **Clickable artist names everywhere**: Artist names in Album Cards, Favorites, Random Mix, Playlist Detail, and Artist Detail tracklists are now clickable links that navigate to the artist page. +- **Duration format supports hours**: Tracks and albums longer than 60 minutes are now displayed as `H:MM:SS` instead of overflowing minutes (e.g. `75:03` → `1:15:03`). +- **Format column**: Codec label no longer includes the "kbps" suffix or the `·` separator — cleaner and fits the narrower column better (e.g. `FLAC 1411` instead of `FLAC · 1411 kbps`). +- **Now Playing sidebar link**: No longer permanently styled as an active menu item. It now only shows the accent background when you are actually on the Now Playing page; at all other times it is distinguished only by its accent text colour. +- **Paused-state indicator in tracklist**: When the currently active track is paused, a dimmed play icon is shown in the `#` column instead of a blank space — making it clear which track is loaded even when playback is stopped. +- **Text selection disabled**: Text can no longer be accidentally selected anywhere in the player by click-dragging or pressing Ctrl+A. Standard input fields are unaffected. +- **Settings — button styles**: "Test connection", "Add server", and "Pick download folder" buttons are now `btn-surface` (with a subtle border) instead of the borderless `btn-ghost` — clearer affordance. +- **Settings — Behavior section icon**: Replaced the generic `Sliders` icon with `AppWindow` for the Behavior section header. +- **`btn-surface` border**: The surface button variant now has a 1 px border that brightens on hover — consistent with the card and input visual language. +- **Queue panel minimum width**: Increased from 250 px to 310 px to prevent layout overflow when the codec/bitrate overlay is visible. +- **Server compatibility hint**: A short note below the Servers section header in Settings clarifies which Subsonic-compatible servers are supported. + +### Fixed + +- **Tracklist `#` column header alignment**: The "Select all" checkbox and the `#` symbol in the header now use the same internal layout as the row cells — ensuring alignment with individual checkboxes and track numbers at all window sizes. +- **Column resize dividers**: The visible 2 px divider line is now placed in the gap between columns rather than inside the cell, so header labels appear visually centred between their dividers. +- **Internet Radio sidebar link hidden**: The navigation entry is temporarily removed until the feature is ready for release. The underlying code remains in place and will be re-enabled without any migration required. + +--- + ## [1.30.0] - 2026-04-03 ### Added diff --git a/package-lock.json b/package-lock.json index ac366ce6..8d89dc08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "psysonic", - "version": "1.26.1", + "version": "1.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "psysonic", - "version": "1.26.1", + "version": "1.30.0", "dependencies": { + "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", @@ -1226,6 +1227,33 @@ "win32" ] }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tauri-apps/api": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", diff --git a/package.json b/package.json index 3b9df589..29674019 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.30.0", + "version": "1.31.0", "private": true, "scripts": { "dev": "vite", @@ -11,6 +11,7 @@ "tauri:build": "tauri build" }, "dependencies": { + "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index 60af478b..9d930278 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Psychotoxic pkgname=psysonic -pkgver=1.30.0 +pkgver=1.31.0 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f70be4f2..a3cc53ab 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3483,10 +3483,11 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.30.0" +version = "1.31.0" dependencies = [ "biquad", "discord-rich-presence", + "futures-util", "md5", "reqwest 0.12.28", "rodio", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 84190744..862c4935 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.30.0" +version = "1.31.0" description = "Psysonic Desktop Music Player" authors = [] license = "" @@ -33,6 +33,7 @@ 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"] } +futures-util = "0.3" md5 = "0.7" tokio = { version = "1", features = ["rt", "time"] } biquad = "0.4" diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index b6334935..496b7cce 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1,5 +1,6 @@ -use std::io::{Cursor, Read, Seek}; -use std::sync::{Arc, Mutex}; +use std::collections::VecDeque; +use std::io::{Cursor, Read, Seek, SeekFrom}; +use std::sync::{Arc, Condvar, Mutex}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::time::{Duration, Instant}; @@ -16,6 +17,7 @@ use symphonia::core::{ probe::Hint, units::{self, Time}, }; +use futures_util::StreamExt; use tauri::{AppHandle, Emitter, State}; // ─── 10-Band Graphic Equalizer ──────────────────────────────────────────────── @@ -30,6 +32,7 @@ struct EqSource> { channels: u16, gains: Arc<[AtomicU32; 10]>, enabled: Arc, + pre_gain: Arc, filters: [[DirectForm2Transposed; 2]; 10], current_gains: [f32; 10], sample_counter: usize, @@ -37,7 +40,7 @@ struct EqSource> { } impl> EqSource { - fn new(inner: S, gains: Arc<[AtomicU32; 10]>, enabled: Arc) -> Self { + fn new(inner: S, gains: Arc<[AtomicU32; 10]>, enabled: Arc, pre_gain: Arc) -> Self { let sample_rate = inner.sample_rate(); let channels = inner.channels(); let filters = std::array::from_fn(|band| { @@ -58,7 +61,7 @@ impl> EqSource { }) }); Self { - inner, sample_rate, channels, gains, enabled, + inner, sample_rate, channels, gains, enabled, pre_gain, filters, current_gains: [0.0; 10], sample_counter: 0, @@ -106,7 +109,9 @@ impl> Iterator for EqSource { let ch = self.channel_idx.min(1); self.channel_idx = (self.channel_idx + 1) % self.channels as usize; - let mut s = sample; + let pre_gain_db = f32::from_bits(self.pre_gain.load(Ordering::Relaxed)); + let pre_gain_factor = 10_f32.powf(pre_gain_db / 20.0); + let mut s = sample * pre_gain_factor; for band in 0..10 { s = self.filters[band][ch].run(s); } @@ -411,6 +416,120 @@ impl> Source for CountingSource { // ─── SizedCursorSource — MediaSource with correct byte_len ──────────────────── // +// ─── RadioBuffer — streaming MediaSource for live HTTP radio ───────────────── +// +// Bridges an async reqwest byte-stream (download task) into a synchronous +// Read+Seek interface that symphonia / rodio can consume. +// +// 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. + +const RADIO_BUF_MAX: usize = 256 * 1024; + +pub(crate) struct RadioInner { + data: VecDeque, + eof: bool, + pos: u64, +} + +// The read-side: given to symphonia / rodio. +struct RadioBuffer { + inner: Arc<(Mutex, Condvar)>, +} + +// The write-side: held by the async download task. +pub struct RadioFeed { + pub inner: Arc<(Mutex, 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 { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + 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")); + } + let (new_g, _) = cvar.wait_timeout(g, rem).unwrap(); + g = new_g; + } + 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 { + fn seek(&mut self, pos: SeekFrom) -> std::io::Result { + // 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")), + } + } +} + +impl MediaSource for RadioBuffer { + fn is_seekable(&self) -> bool { false } + fn byte_len(&self) -> Option { None } +} + +// ─── SizedCursorSource — correct byte_len for seekable in-memory sources ────── +// // rodio's internal ReadSeekSource wraps Cursor> but hardcodes // byte_len() → None. This tells symphonia "stream length unknown", which // prevents the FLAC demuxer from seeking (it validates seek offsets against @@ -552,6 +671,50 @@ impl SizedDecoder { }) } + /// Build a decoder from any `MediaSource` (e.g. `RadioBuffer`). + /// Uses `enable_gapless: false` — live streams are not seekable; gapless + /// trimming requires seeking to read the LAME/iTunSMPB end-padding info. + fn new_streaming(media: Box, format_hint: Option<&str>) -> Result { + let mss = MediaSourceStream::new(media, Default::default()); + let mut hint = Hint::new(); + if let Some(ext) = format_hint { hint.with_extension(ext); } + let format_opts = FormatOptions { enable_gapless: false, ..Default::default() }; + let probed = symphonia::default::get_probe() + .format(&hint, mss, &format_opts, &MetadataOptions::default()) + .map_err(|e| format!("radio: format probe failed: {e}"))?; + + let track = probed.format.tracks().iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| "radio: no audio track found".to_string())?; + 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()) + .map_err(|e| format!("radio: codec init failed: {e}"))?; + let mut format = probed.format; + + let mut errors = 0usize; + let decoded = loop { + let packet = match format.next_packet() { + Ok(p) => p, + Err(_) => break decoder.last_decoded(), + }; + if packet.track_id() != track_id { continue; } + match decoder.decode(&packet) { + Ok(d) => break d, + Err(symphonia::core::errors::Error::DecodeError(_)) => { + errors += 1; + if errors > DECODE_MAX_RETRIES { return Err("radio: too many decode errors".into()); } + } + Err(e) => return Err(format!("radio: decode error: {e}")), + } + }; + let spec = decoded.spec().to_owned(); + let buffer = Self::make_buffer(decoded, &spec); + Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec }) + } + #[inline] fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer { let duration = units::Duration::from(decoded.capacity() as u64); @@ -765,6 +928,7 @@ fn build_source( duration_hint: f64, eq_gains: Arc<[AtomicU32; 10]>, eq_enabled: Arc, + eq_pre_gain: Arc, done_flag: Arc, fade_in_dur: Duration, sample_counter: Arc, @@ -824,7 +988,7 @@ fn build_source( let fadeout_trigger = Arc::new(AtomicBool::new(false)); let fadeout_samples = Arc::new(AtomicU64::new(0)); - let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled); + let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain); let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur); let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone()); let notifying = NotifyingSource::new(fade_out, done_flag); @@ -870,6 +1034,7 @@ pub struct AudioEngine { pub http_client: reqwest::Client, pub eq_gains: Arc<[AtomicU32; 10]>, pub eq_enabled: Arc, + pub eq_pre_gain: Arc, pub preloaded: Arc>>, pub crossfade_enabled: Arc, pub crossfade_secs: Arc, @@ -979,6 +1144,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { .unwrap_or_default(), eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))), eq_enabled: Arc::new(AtomicBool::new(false)), + eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())), preloaded: Arc::new(Mutex::new(None)), crossfade_enabled: Arc::new(AtomicBool::new(false)), crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())), @@ -1041,7 +1207,17 @@ async fn fetch_data( app.emit("audio:error", &msg).ok(); return Err(msg); } - let data: Vec = response.bytes().await.map_err(|e| e.to_string())?.into(); + // Stream the body, checking gen between chunks so a rapid manual skip can + // abort a superseded download mid-flight and free bandwidth for the new one. + let hint = response.content_length().unwrap_or(0) as usize; + let mut stream = response.bytes_stream(); + let mut data = Vec::with_capacity(hint); + while let Some(chunk) = stream.next().await { + if state.generation.load(Ordering::SeqCst) != gen { + return Ok(None); // superseded — abort + } + data.extend_from_slice(&chunk.map_err(|e| e.to_string())?); + } Ok(Some(data)) } @@ -1181,6 +1357,7 @@ pub async fn audio_play( duration_hint, state.eq_gains.clone(), state.eq_enabled.clone(), + state.eq_pre_gain.clone(), done_flag.clone(), fade_in_dur, state.samples_played.clone(), @@ -1350,7 +1527,16 @@ pub async fn audio_chain_preload( if !resp.status().is_success() { return Ok(()); // silently fail — audio_play will retry } - resp.bytes().await.map_err(|e| e.to_string())?.into() + let hint = resp.content_length().unwrap_or(0) as usize; + let mut stream = resp.bytes_stream(); + let mut buf = Vec::with_capacity(hint); + while let Some(chunk) = stream.next().await { + if state.generation.load(Ordering::SeqCst) != snapshot_gen { + return Ok(()); // superseded by manual skip — abort download + } + buf.extend_from_slice(&chunk.map_err(|e| e.to_string())?); + } + buf } } }; @@ -1376,6 +1562,7 @@ pub async fn audio_chain_preload( duration_hint, state.eq_gains.clone(), state.eq_enabled.clone(), + state.eq_pre_gain.clone(), done_next.clone(), Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click chain_counter.clone(), @@ -1461,6 +1648,15 @@ fn spawn_progress_task( // ready, transition seamlessly: swap tracking state, emit // audio:track_switched for the new track, and continue the loop. if current_done.load(Ordering::SeqCst) { + // Radio (dur == 0): stream exhausted / connection dropped → stop. + let cur_dur = current_arc.lock().unwrap().duration_secs; + if cur_dur <= 0.0 { + eprintln!("[radio] current_done fired → emitting audio:ended (dur=0)"); + gen_counter.fetch_add(1, Ordering::SeqCst); + app.emit("audio:ended", ()).ok(); + break; + } + let chained = chained_arc.lock().unwrap().take(); if let Some(info) = chained { // Swap to the chained source's done flag. @@ -1663,9 +1859,56 @@ pub fn audio_update_replay_gain( } } +/// Proxy: fetches https://autoeq.app/entries via Rust to bypass WebView CORS restrictions. #[tauri::command] -pub fn audio_set_eq(gains: [f32; 10], enabled: bool, state: State<'_, AudioEngine>) { +pub async fn autoeq_entries(state: State<'_, AudioEngine>) -> Result { + state.http_client + .get("https://autoeq.app/entries") + .send().await.map_err(|e| e.to_string())? + .text().await.map_err(|e| e.to_string()) +} + +/// Fetches the AutoEQ GraphicEQ 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) +/// +/// We try the rig-prefixed path first (when rig is present), then fall back to form-only. +#[tauri::command] +pub async fn autoeq_fetch_profile( + name: String, + source: String, + rig: Option, + form: String, + state: State<'_, AudioEngine>, +) -> Result { + let base = "https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results"; + let filename = format!("{} GraphicEQ.txt", name); + + let candidates: Vec = if let Some(ref r) = rig { + vec![ + format!("{}/{}/{} {}/{}/{}", base, source, r, form, name, filename), + format!("{}/{}/{}/{}/{}", base, source, form, name, filename), + ] + } else { + vec![format!("{}/{}/{}/{}/{}", base, source, form, name, filename)] + }; + + for url in &candidates { + let resp = state.http_client.get(url).send().await.map_err(|e| e.to_string())?; + if resp.status().is_success() { + return resp.text().await.map_err(|e| e.to_string()); + } + } + + Err(format!("GraphicEQ profile not found for '{}'", name)) +} + +#[tauri::command] +pub fn audio_set_eq(gains: [f32; 10], enabled: bool, pre_gain: f32, state: State<'_, AudioEngine>) { state.eq_enabled.store(enabled, Ordering::Relaxed); + state.eq_pre_gain.store(pre_gain.clamp(-30.0, 6.0).to_bits(), Ordering::Relaxed); for (i, &gain) in gains.iter().enumerate() { state.eq_gains[i].store(gain.clamp(-12.0, 12.0).to_bits(), Ordering::Relaxed); } @@ -1697,6 +1940,200 @@ pub async fn audio_preload( Ok(()) } +/// 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. +#[tauri::command] +pub async fn audio_play_radio( + url: String, + volume: f32, + app: AppHandle, + state: State<'_, AudioEngine>, +) -> Result<(), String> { + let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1; + + // Cancel pending chain and fading-out sink immediately so audio stops. + *state.chained_info.lock().unwrap() = None; + { + let mut cur = state.current.lock().unwrap(); + if let Some(old) = cur.sink.take() { old.stop(); } + } + if let Some(old) = state.fading_out_sink.lock().unwrap().take() { old.stop(); } + + // Open the HTTP stream. + 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 })?; + + if !response.status().is_success() { + let m = format!("radio: HTTP {}", response.status()); + app.emit("audio:error", &m).ok(); + 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 = 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 (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 = 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 decoder = tokio::task::spawn_blocking(move || { + SizedDecoder::new_streaming(Box::new(radio_buf), fmt_hint.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::()); + 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()); + let notifying = NotifyingSource::new(fade_out, done_flag.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.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.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); + } + + state.current_sample_rate.store(sample_rate, Ordering::Relaxed); + state.current_channels.store(channels as u32, Ordering::Relaxed); + + app.emit("audio:playing", 0.0f64).ok(); + + spawn_progress_task( + gen, + state.generation.clone(), + state.current.clone(), + state.chained_info.clone(), + state.crossfade_enabled.clone(), + state.crossfade_secs.clone(), + done_flag, + app, + state.samples_played.clone(), + state.current_sample_rate.clone(), + state.current_channels.clone(), + state.gapless_switch_at.clone(), + ); + + Ok(()) +} + #[tauri::command] pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) { state.crossfade_enabled.store(enabled, Ordering::Relaxed); diff --git a/src-tauri/src/discord.rs b/src-tauri/src/discord.rs index 399cf078..fc253319 100644 --- a/src-tauri/src/discord.rs +++ b/src-tauri/src/discord.rs @@ -10,7 +10,7 @@ /// so the app always starts cleanly regardless of Discord availability. use discord_rich_presence::{ - activity::{Activity, Assets, Timestamps}, + activity::{Activity, ActivityType, Assets, Timestamps}, DiscordIpc, DiscordIpcClient, }; use std::sync::Mutex; @@ -56,7 +56,10 @@ pub fn discord_update_presence( let client = guard.as_mut().unwrap(); - let state_text = format!("by {artist}"); + // Discord RPC only exposes two visible text rows (details + state). + // The application name "Psysonic" is shown automatically by Discord as the + // header line. Album goes into large_text — visible as a hover tooltip on + // the cover art icon. let large_text = album.as_deref().unwrap_or("Psysonic"); let assets = Assets::new() @@ -64,8 +67,9 @@ pub fn discord_update_presence( .large_text(large_text); let mut activity = Activity::new() + .activity_type(ActivityType::Listening) .details(&title) - .state(&state_text) + .state(&artist) .assets(assets); // Start timestamp: Discord auto-counts up from this point. We back-calculate diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5b091f48..6041d97c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -536,7 +536,10 @@ pub fn run() { audio::audio_set_volume, audio::audio_update_replay_gain, audio::audio_set_eq, + audio::autoeq_entries, + audio::autoeq_fetch_profile, audio::audio_preload, + audio::audio_play_radio, audio::audio_set_crossfade, audio::audio_set_gapless, audio::audio_chain_preload, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index adb59c9b..eff12c47 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.30.0", + "version": "1.31.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 2eb59d9b..cf94f292 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import SearchResults from './pages/SearchResults'; import AdvancedSearch from './pages/AdvancedSearch'; import Playlists from './pages/Playlists'; import PlaylistDetail from './pages/PlaylistDetail'; +import InternetRadio from './pages/InternetRadio'; import NowPlayingPage from './pages/NowPlaying'; import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; @@ -141,7 +142,7 @@ function AppShell() { const handleMouseMove = useCallback((e: MouseEvent) => { if (isDraggingQueue) { - const newWidth = Math.max(250, Math.min(window.innerWidth - e.clientX, 500)); + const newWidth = Math.max(310, Math.min(window.innerWidth - e.clientX, 500)); setQueueWidth(newWidth); } }, [isDraggingQueue]); @@ -185,14 +186,36 @@ function AppShell() { // from the OS file manager) is dropped on the document body. const blockDrop = (e: DragEvent) => { e.preventDefault(); }; + // Block Ctrl+A / Cmd+A "select all" — WebKit ignores user-select:none for keyboard shortcuts + const blockSelectAll = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + const target = e.target as HTMLElement; + // Allow Ctrl+A inside actual text inputs and textareas + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + e.preventDefault(); + } + }; + + // Block mouse drag selection — WebKitGTK ignores user-select:none on * for drag selection + const blockSelectStart = (e: Event) => { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + if ((target as HTMLElement).closest('[data-selectable]')) return; + e.preventDefault(); + }; + document.addEventListener('dragover', allow); document.addEventListener('dragenter', allow); document.addEventListener('drop', blockDrop); + document.addEventListener('keydown', blockSelectAll, true); + document.addEventListener('selectstart', blockSelectStart); return () => { document.removeEventListener('dragover', allow); document.removeEventListener('dragenter', allow); document.removeEventListener('drop', blockDrop); + document.removeEventListener('keydown', blockSelectAll, true); + document.removeEventListener('selectstart', blockSelectStart); }; }, []); @@ -258,6 +281,7 @@ function AppShell() { } /> } /> } /> + } /> diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index fc682f36..5c9982e5 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -88,6 +88,14 @@ export interface SubsonicSong { }; } +export interface InternetRadioStation { + id: string; + name: string; + streamUrl: string; + homepageUrl?: string; + coverArt?: string; // Navidrome v0.61.0+ +} + export interface SubsonicPlaylist { id: string; name: string; @@ -442,3 +450,36 @@ export async function getNowPlaying(): Promise { return []; } } + + +// ─── Internet Radio ─────────────────────────────────────────── +export async function getInternetRadioStations(): Promise { + try { + const data = await api<{ internetRadioStations?: { internetRadioStation?: InternetRadioStation[] } }>( + 'getInternetRadioStations.view' + ); + return data.internetRadioStations?.internetRadioStation ?? []; + } catch { + return []; + } +} + +export async function createInternetRadioStation( + name: string, streamUrl: string, homepageUrl?: string +): Promise { + const params: Record = { name, streamUrl }; + if (homepageUrl) params.homepageUrl = homepageUrl; + await api('createInternetRadioStation.view', params); +} + +export async function updateInternetRadioStation( + id: string, name: string, streamUrl: string, homepageUrl?: string +): Promise { + const params: Record = { id, name, streamUrl }; + if (homepageUrl) params.homepageUrl = homepageUrl; + await api('updateInternetRadioStation.view', params); +} + +export async function deleteInternetRadioStation(id: string): Promise { + await api('deleteInternetRadioStation.view', { id }); +} diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index deae1365..94d8d78f 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -81,7 +81,11 @@ function AlbumCard({ album }: AlbumCardProps) {

{album.name}

-

{album.artist}

+

{ if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }} + >{album.artist}

{album.year &&

{album.year}

}
diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 45ca4bf6..9d0c1014 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -7,8 +7,10 @@ import CoverLightbox from './CoverLightbox'; import { useTranslation } from 'react-i18next'; function formatDuration(seconds: number): string { - const m = Math.floor(seconds / 60); - const s = seconds % 60; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; return `${m}:${s.toString().padStart(2, '0')}`; } diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 4795bd6b..31f910a6 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -1,22 +1,25 @@ -import React, { useState, useEffect, useMemo } from 'react'; -import { Play, Heart, ListPlus, X } from 'lucide-react'; +import React, { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import { Play, Heart, ListPlus, X, ChevronDown, Check } from 'lucide-react'; import { SubsonicSong } from '../api/subsonic'; import { Track, usePlayerStore, songToTrack } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import { useDragDrop } from '../contexts/DragDropContext'; import { AddToPlaylistSubmenu } from './ContextMenu'; function formatDuration(seconds: number): string { - const m = Math.floor(seconds / 60); - const s = seconds % 60; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; return `${m}:${s.toString().padStart(2, '0')}`; } function codecLabel(song: { suffix?: string; bitRate?: number }): string { const parts: string[] = []; if (song.suffix) parts.push(song.suffix.toUpperCase()); - if (song.bitRate) parts.push(`${song.bitRate} kbps`); - return parts.join(' · '); + if (song.bitRate) parts.push(`${song.bitRate}`); + return parts.join(' '); } function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) { @@ -42,6 +45,89 @@ function StarRating({ value, onChange }: { value: number; onChange: (r: number) ); } +// ── Column configuration ────────────────────────────────────────────────────── + +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; + +type ColKey = (typeof COLUMNS)[number]['key']; + +const DEFAULT_WIDTHS: Record = Object.fromEntries( + COLUMNS.map(c => [c.key, c.defaultWidth]) +) as Record; + +const DEFAULT_VISIBLE = new Set([ + 'num', 'title', 'artist', 'favorite', 'rating', 'duration', 'format', 'genre', +]); + +function loadColPrefs(): { widths: Record; visible: Set } { + 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((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, visible: Set) { + 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, + vCols: readonly { readonly key: string; readonly minWidth: number; readonly fixed: boolean }[], + targetW: number, + gapPx: number +): Record { + 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; +} + +// ── Props ───────────────────────────────────────────────────────────────────── + interface AlbumTrackListProps { songs: SubsonicSong[]; hasVariousArtists: boolean; @@ -68,15 +154,153 @@ export default function AlbumTrackList({ onContextMenu, }: AlbumTrackListProps) { const { t } = useTranslation(); + const navigate = useNavigate(); const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); const psyDrag = useDragDrop(); - // ── Bulk select ─────────────────────────────────────────────────── + // ── Bulk select ─────────────────────────────────────────────────────────── const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastSelectedIdx, setLastSelectedIdx] = useState(null); const [showPlPicker, setShowPlPicker] = useState(false); + // ── Column state ────────────────────────────────────────────────────────── + const [colWidths, setColWidths] = useState>(() => loadColPrefs().widths); + const [colVisible, setColVisible] = useState>(() => loadColPrefs().visible); + const [pickerOpen, setPickerOpen] = useState(false); + const pickerRef = useRef(null); + const tracklistRef = useRef(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; 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; + }); + }; + const toggleSelect = (id: string, globalIdx: number, shift: boolean) => { setSelectedIds(prev => { const next = new Set(prev); @@ -99,7 +323,6 @@ export default function AlbumTrackList({ if (!contextMenuOpen) setContextMenuSongId(null); }, [contextMenuOpen]); - // Close playlist picker on outside click useEffect(() => { if (!showPlPicker) return; const handler = (e: MouseEvent) => { @@ -110,6 +333,15 @@ 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(); @@ -123,8 +355,137 @@ export default function AlbumTrackList({ const inSelectMode = selectedIds.size > 0; + // ── Header cell renderer ────────────────────────────────────────────────── + const renderHeaderCell = (colDef: (typeof COLUMNS)[number], colIndex: number) => { + const key = colDef.key as ColKey; + const isLastCol = colIndex === visibleCols.length - 1; + const isCentered = key === 'favorite' || key === 'rating' || key === 'duration'; + const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey as string}`) : ''; + + // 'num' header mirrors the row-cell layout exactly so checkbox + # stay aligned + if (key === 'num') { + return ( +
+ { e.stopPropagation(); toggleAll(); }} + style={{ cursor: 'pointer' }} + /> + # +
+ ); + } + + return ( +
+ + {label} + + {/* Resize handle on all non-fixed columns except the last */} + {!isLastCol && !colDef.fixed && ( +
startResize(e, colIndex)} + /> + )} +
+ ); + }; + + // ── Row cell renderer ───────────────────────────────────────────────────── + const renderRowCell = (key: ColKey, song: SubsonicSong, globalIdx: number) => { + switch (key) { + case 'num': + return ( +
{ e.stopPropagation(); onPlaySong(song); }} + > + { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }} + /> + {currentTrack?.id === song.id && isPlaying && ( + +
+
+ )} + + {song.track ?? '—'} +
+ ); + case 'title': + return ( +
+ {song.title} +
+ ); + case 'artist': + return ( +
+ { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }} + > + {song.artist} + +
+ ); + case 'favorite': + return ( +
+ +
+ ); + case 'rating': + return ( + onRate(song.id, r)} + /> + ); + case 'duration': + return ( +
+ {formatDuration(song.duration)} +
+ ); + case 'format': + return ( +
+ {(song.suffix || song.bitRate) && ( + {codecLabel(song)} + )} +
+ ); + case 'genre': + return ( +
+ {song.genre ?? '—'} +
+ ); + default: + return null; + } + }; + return ( -
+
{/* ── Bulk action bar ── */} {inSelectMode && ( @@ -158,20 +519,47 @@ export default function AlbumTrackList({
)} -
-
- {inSelectMode - ? - : '#'} + {/* ── Header ── */} +
+
+ {visibleCols.map((colDef, colIndex) => renderHeaderCell(colDef, colIndex))} +
+ + {/* Column visibility picker */} +
+ + {pickerOpen && ( +
+
{t('albumDetail.columns')}
+ {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); + return ( + + ); + })} +
+ )}
-
{t('albumDetail.trackTitle')}
-
{t('albumDetail.trackArtist')}
-
{t('albumDetail.trackFavorite')}
-
{t('albumDetail.trackRating')}
-
{t('albumDetail.trackDuration')}
-
{t('albumDetail.trackFormat')}
+ {/* ── Tracks ── */} {discNums.map(discNum => (
{isMultiDisc && ( @@ -186,6 +574,7 @@ export default function AlbumTrackList({
{ if ((e.target as HTMLElement).closest('button, a, input')) return; if (inSelectMode) { @@ -216,59 +605,13 @@ export default function AlbumTrackList({ document.addEventListener('mouseup', onUp); }} > -
{ e.stopPropagation(); onPlaySong(song); }} - > - { e.stopPropagation(); toggleSelect(song.id, globalIdx, e.shiftKey); }} - /> - - {currentTrack?.id === song.id && isPlaying - ?
- : } -
-
-
- {song.title} -
-
- {song.artist} -
-
- -
- onRate(song.id, r)} - /> -
- {formatDuration(song.duration)} -
-
- {(song.suffix || song.bitRate) && ( - {codecLabel(song)} - )} -
+ {visibleCols.map(colDef => renderRowCell(colDef.key as ColKey, song, globalIdx))}
); })}
))} -
- {t('albumDetail.trackTotal')} - {formatDuration(totalDuration)} -
); } diff --git a/src/components/Equalizer.tsx b/src/components/Equalizer.tsx index 91704a04..a1226524 100644 --- a/src/components/Equalizer.tsx +++ b/src/components/Equalizer.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { Save, Trash2, RotateCcw } from 'lucide-react'; +import { Save, Trash2, RotateCcw, Search, X, ChevronDown, ChevronUp } from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; import CustomSelect from './CustomSelect'; import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore'; import { useThemeStore } from '../store/themeStore'; @@ -147,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.5) * 0.5; // snap to 0.5 dB + const gain = Math.round(pctToGain(pct) / 0.1) * 0.1; // snap to 0.1 dB onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain))); }, [onChange]); @@ -182,20 +183,63 @@ function VerticalFader({ value, disabled, onChange }: FaderProps) { ); } +// ─── AutoEQ helpers ─────────────────────────────────────────────────────────── + +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)); + + 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; + }); +} + // ─── Main component ─────────────────────────────────────────────────────────── export default function Equalizer() { const { t } = useTranslation(); const gains = useEqStore(s => s.gains); const enabled = useEqStore(s => s.enabled); + const preGain = useEqStore(s => s.preGain); const activePreset = useEqStore(s => s.activePreset); const customPresets = useEqStore(s => s.customPresets); - const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore(); + const { setBandGain, setEnabled, setPreGain, applyPreset, applyAutoEq, saveCustomPreset, deleteCustomPreset } = useEqStore(); const [saveName, setSaveName] = useState(''); const [showSave, setShowSave] = useState(false); const canvasRef = useRef(null); + // AutoEQ state + const [autoEqOpen, setAutoEqOpen] = useState(false); + const [autoEqQuery, setAutoEqQuery] = useState(''); + const [autoEqResults, setAutoEqResults] = useState([]); + const [autoEqLoading, setAutoEqLoading] = useState(false); + const [autoEqError, setAutoEqError] = useState(null); + const [autoEqApplied, setAutoEqApplied] = useState(null); + const [entriesLoading, setEntriesLoading] = useState(false); + const entriesCacheRef = useRef | null>(null); + const theme = useThemeStore(s => s.theme); const redraw = useCallback(() => { @@ -216,6 +260,63 @@ export default function Equalizer() { return () => ro.disconnect(); }, [redraw]); + // AutoEQ: load entries index lazily when section opens, then filter client-side + async function ensureEntries() { + if (entriesCacheRef.current) return; + setEntriesLoading(true); + setAutoEqError(null); + try { + const json = await invoke('autoeq_entries'); + entriesCacheRef.current = JSON.parse(json); + } catch (e: unknown) { + setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqError')); + } finally { + setEntriesLoading(false); + } + } + + useEffect(() => { + const q = autoEqQuery.trim().toLowerCase(); + if (!entriesCacheRef.current || q.length < 1) { setAutoEqResults([]); return; } + const flat: AutoEqResult[] = []; + for (const [name, variants] of Object.entries(entriesCacheRef.current)) { + if (!name.toLowerCase().includes(q)) continue; + for (const v of variants) { + flat.push({ name, source: v.source, rig: v.rig, form: v.form }); + if (flat.length >= 20) break; + } + if (flat.length >= 20) break; + } + setAutoEqResults(flat); + // entriesLoading in deps: re-runs after entries finish loading so a query typed + // during loading produces results immediately without needing a re-type. + }, [autoEqQuery, entriesLoading]); + + async function applyAutoEqResult(result: AutoEqResult) { + setAutoEqLoading(true); + setAutoEqError(null); + try { + const text = await invoke('autoeq_fetch_profile', { + name: result.name, + source: result.source, + rig: result.rig ?? null, + 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); + setAutoEqApplied(result.name); + setAutoEqQuery(''); + setAutoEqResults([]); + setTimeout(() => setAutoEqApplied(null), 3000); + } catch (e: unknown) { + setAutoEqError(e instanceof Error ? e.message : t('settings.eqAutoEqFetchError')); + } finally { + setAutoEqLoading(false); + } + } + const allPresets = [...BUILTIN_PRESETS, ...customPresets]; const selectValue = activePreset ?? '__custom__'; const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset); @@ -279,6 +380,74 @@ export default function Equalizer() {
)} + {/* AutoEQ section */} +
+ + + {autoEqOpen && ( +
+
+ { setAutoEqQuery(e.target.value); setAutoEqError(null); }} + autoFocus + style={{ flex: 1, padding: '6px 12px', fontSize: 13 }} + /> + {autoEqQuery && ( + + )} +
+ + {(entriesLoading || autoEqLoading) && ( +
{t('settings.eqAutoEqSearching')}
+ )} + {autoEqError && ( +
{autoEqError}
+ )} + {autoEqApplied && ( +
✓ {autoEqApplied}
+ )} + + {autoEqResults.length > 0 && ( +
+ {autoEqResults.map((r, i) => ( + + ))} +
+ )} + + {!entriesLoading && !autoEqLoading && !autoEqError && autoEqQuery.length >= 2 && autoEqResults.length === 0 && ( +
{t('settings.eqAutoEqNoResults')}
+ )} +
+ )} +
+ {/* EQ panel */}
{/* Frequency response */} @@ -313,6 +482,27 @@ export default function Equalizer() {
))}
+ + {/* Pre-gain row */} +
+ {t('settings.eqPreGain')} + setPreGain(parseFloat(e.target.value))} + /> + + {preGain > 0 ? '+' : ''}{preGain.toFixed(1)} dB + + {preGain !== 0 && ( + + )} +
); diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 6f40d402..68de13f7 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, - Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal + Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal, Cast } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; @@ -31,7 +31,7 @@ export default function PlayerBar() { const showLyrics = useLyricsStore(s => s.showLyrics); const activeTab = useLyricsStore(s => s.activeTab); const { - currentTrack, isPlaying, currentTime, volume, + currentTrack, currentRadio, isPlaying, currentTime, volume, togglePlay, next, previous, setVolume, stop, toggleRepeat, repeatMode, toggleFullscreen, lastfmLoved, toggleLastfmLove, @@ -40,6 +40,8 @@ export default function PlayerBar() { } = usePlayerStore(); const { lastfmSessionKey } = useAuthStore(); + const isRadio = !!currentRadio; + const isStarred = currentTrack ? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred) : false; @@ -57,6 +59,13 @@ export default function PlayerBar() { }, [currentTrack, isStarred, setStarredOverride]); const duration = currentTrack?.duration ?? 0; + + // Cover art: prefer radio station art, fall back to track art. + const radioCoverSrc = useMemo( + () => currentRadio?.coverArt ? buildCoverArtUrl(currentRadio.coverArt, 128) : '', + [currentRadio?.coverArt] + ); + const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(currentRadio.coverArt, 128) : ''; const coverSrc = useMemo(() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt]); const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 128) : ''; @@ -80,11 +89,24 @@ export default function PlayerBar() { {/* Track Info */}
currentTrack && toggleFullscreen()} - data-tooltip={currentTrack ? t('player.openFullscreen') : undefined} + className={`player-album-art-wrap ${currentTrack && !isRadio ? 'clickable' : ''}`} + onClick={() => !isRadio && currentTrack && toggleFullscreen()} + data-tooltip={!isRadio && currentTrack ? t('player.openFullscreen') : undefined} > - {currentTrack?.coverArt ? ( + {isRadio ? ( + currentRadio?.coverArt ? ( + + ) : ( +
+ +
+ ) + ) : currentTrack?.coverArt ? (
)} - {currentTrack && ( + {currentTrack && !isRadio && ( @@ -104,19 +126,19 @@ export default function PlayerBar() {
currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)} + style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }} + onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)} /> currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} + style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }} + onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} />
- {currentTrack && ( + {currentTrack && !isRadio && ( )} - {currentTrack && lastfmSessionKey && ( + {currentTrack && !isRadio && lastfmSessionKey && ( - - - - {/* Drop indicator below last row or between rows */} {isDragging && dropTargetIdx?.idx === idx && !dropTargetIdx.before && (
)} @@ -726,7 +735,11 @@ export default function PlaylistDetail() { {song.title}
- {song.artist} + { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }} + >{song.artist}
{/* no star/rating for suggestions */}
diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 32290fb4..7125ee3a 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -333,7 +333,7 @@ export default function RandomMix() {
{t('randomMix.trackFavorite')}
{t('randomMix.trackDuration')}
- {genreMixSongs.map(song => { + {genreMixSongs.map((song, idx) => { const track = songToTrack(song); const queueSongs = genreMixSongs.map(songToTrack); const isCurrentTrack = currentTrack?.id === song.id; @@ -364,12 +364,10 @@ export default function RandomMix() { document.addEventListener('mouseup', onUp); }} > -
{ e.stopPropagation(); playTrack(track, queueSongs); }}> - - {isCurrentTrack && isPlaying - ?
- : } -
+
{ e.stopPropagation(); playTrack(track, queueSongs); }}> + {isCurrentTrack && isPlaying &&
} + + {idx + 1}
{song.title}
@@ -428,7 +426,7 @@ export default function RandomMix() {
{t('randomMix.trackDuration')}
- {filteredSongs.map((song) => { + {filteredSongs.map((song, idx) => { const track = songToTrack(song); const queueSongs = filteredSongs.map(songToTrack); const isCurrentTrack = currentTrack?.id === song.id; @@ -469,12 +467,10 @@ export default function RandomMix() { document.addEventListener('mouseup', onUp); }} > -
{ e.stopPropagation(); playTrack(track, queueSongs); }}> - - {isCurrentTrack && isPlaying - ?
- : } -
+
{ e.stopPropagation(); playTrack(track, queueSongs); }}> + {isCurrentTrack && isPlaying &&
} + + {idx + 1}
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 38e2feea..316b367b 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown, - GripVertical, PanelLeft, RotateCcw, LayoutGrid + GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow } from 'lucide-react'; import { invoke } from '@tauri-apps/api/core'; import { open as openUrl } from '@tauri-apps/plugin-shell'; @@ -453,7 +453,7 @@ export default function Settings() { {/* Cache */}
- +

{t('settings.behavior')}

@@ -847,6 +847,9 @@ export default function Settings() {

{t('settings.servers')}

+
+ {t('settings.serverCompatible')} +
{auth.servers.length === 0 && !showAddForm ? (
@@ -876,7 +879,7 @@ export default function Settings() { {status === 'error' && } {status === 'testing' &&
} )} @@ -993,7 +996,7 @@ export default function Settings() { {/* Downloads + Tray */}
- +

{t('settings.behavior')}

@@ -1060,7 +1063,7 @@ export default function Settings() { )} -
diff --git a/src/store/eqStore.ts b/src/store/eqStore.ts index 01ba5b9d..f1aa5800 100644 --- a/src/store/eqStore.ts +++ b/src/store/eqStore.ts @@ -37,19 +37,22 @@ export const BUILTIN_PRESETS: EqPreset[] = [ interface EqState { gains: number[]; // 10 values, -12 to +12 dB enabled: boolean; + preGain: number; // pre-amplification in dB (-30 to +6), applied before bands activePreset: string | null; customPresets: EqPreset[]; setBandGain: (index: number, gain: number) => void; setEnabled: (v: boolean) => void; + setPreGain: (v: number) => void; applyPreset: (name: string) => void; + applyAutoEq: (name: string, gains: number[], preGain: number) => void; saveCustomPreset: (name: string) => void; deleteCustomPreset: (name: string) => void; syncToRust: () => void; } -function syncEq(gains: number[], enabled: boolean) { - invoke('audio_set_eq', { gains: gains.map(g => g), enabled }).catch(() => {}); +function syncEq(gains: number[], enabled: boolean, preGain: number) { + invoke('audio_set_eq', { gains: gains.map(g => g), enabled, preGain }).catch(() => {}); } export const useEqStore = create()( @@ -57,6 +60,7 @@ export const useEqStore = create()( (set, get) => ({ gains: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], enabled: false, + preGain: 0, activePreset: 'Flat', customPresets: [], @@ -65,12 +69,25 @@ export const useEqStore = create()( const gains = [...get().gains]; gains[index] = clamped; set({ gains, activePreset: null }); - syncEq(gains, get().enabled); + syncEq(gains, get().enabled, get().preGain); }, setEnabled: (v) => { set({ enabled: v }); - syncEq(get().gains, v); + syncEq(get().gains, v, get().preGain); + }, + + setPreGain: (v) => { + const clamped = Math.max(-30, Math.min(6, v)); + set({ preGain: clamped, activePreset: null }); + syncEq(get().gains, get().enabled, clamped); + }, + + applyAutoEq: (name, gains, preGain) => { + const clampedPreGain = Math.max(-30, Math.min(6, preGain)); + const clampedGains = gains.map(g => Math.max(-12, Math.min(12, g))); + set({ gains: clampedGains, preGain: clampedPreGain, activePreset: name }); + syncEq(clampedGains, get().enabled, clampedPreGain); }, applyPreset: (name) => { @@ -78,7 +95,7 @@ export const useEqStore = create()( const preset = all.find(p => p.name === name); if (!preset) return; set({ gains: [...preset.gains], activePreset: name }); - syncEq(preset.gains, get().enabled); + syncEq(preset.gains, get().enabled, get().preGain); }, saveCustomPreset: (name) => { @@ -95,8 +112,8 @@ export const useEqStore = create()( }, syncToRust: () => { - const { gains, enabled } = get(); - syncEq(gains, enabled); + const { gains, enabled, preGain } = get(); + syncEq(gains, enabled, preGain); }, }), { @@ -105,6 +122,7 @@ export const useEqStore = create()( partialize: (s) => ({ gains: s.gains, enabled: s.enabled, + preGain: s.preGain, activePreset: s.activePreset, customPresets: s.customPresets, }), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 0f6f24fb..5c88ebf4 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { showToast } from '../utils/toast'; -import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic'; +import { buildStreamUrl, buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation } from '../api/subsonic'; import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm'; import { useAuthStore } from './authStore'; import { useOfflineStore } from './offlineStore'; @@ -60,6 +60,7 @@ export function songToTrack(song: SubsonicSong): Track { interface PlayerState { currentTrack: Track | null; + currentRadio: InternetRadioStation | null; queue: Track[]; queueIndex: number; isPlaying: boolean; @@ -73,6 +74,7 @@ interface PlayerState { starredOverrides: Record; setStarredOverride: (id: string, starred: boolean) => void; + playRadio: (station: InternetRadioStation) => void; playTrack: (track: Track, queue?: Track[], manual?: boolean) => void; pause: () => void; resume: () => void; @@ -262,6 +264,13 @@ function handleAudioEnded() { return; } + // Radio stream disconnected — just stop; don't advance queue. + if (usePlayerStore.getState().currentRadio) { + isAudioPaused = false; + usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 }); + return; + } + const { repeatMode, currentTrack, queue } = usePlayerStore.getState(); isAudioPaused = false; usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 }); @@ -481,6 +490,7 @@ export const usePlayerStore = create()( persist( (set, get) => ({ currentTrack: null, + currentRadio: null, queue: [], queueIndex: 0, isPlaying: false, @@ -572,7 +582,31 @@ export const usePlayerStore = create()( 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 }); + set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0, currentRadio: null }); + }, + + // ── playRadio ──────────────────────────────────────────────────────────── + playRadio: (station) => { + const { volume } = get(); + ++playGeneration; + isAudioPaused = false; + 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); + set({ isPlaying: false, currentRadio: null }); + }); + set({ + currentRadio: station, + currentTrack: null, + queue: [], + queueIndex: 0, + isPlaying: true, + progress: 0, + currentTime: 0, + buffered: 0, + scrobbled: true, // no scrobbling for radio + }); }, // ── playTrack ──────────────────────────────────────────────────────────── diff --git a/src/store/sidebarStore.ts b/src/store/sidebarStore.ts index 4a32cb50..af9c3052 100644 --- a/src/store/sidebarStore.ts +++ b/src/store/sidebarStore.ts @@ -18,6 +18,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [ { id: 'randomMix', visible: true }, { id: 'favorites', visible: true }, { id: 'playlists', visible: true }, + { id: 'radio', visible: true }, { id: 'statistics', visible: true }, { id: 'help', visible: true }, ]; @@ -42,6 +43,16 @@ export const useSidebarStore = create()( reset: () => set({ items: DEFAULT_SIDEBAR_ITEMS }), }), - { name: 'psysonic_sidebar' } + { + name: 'psysonic_sidebar', + onRehydrateStorage: () => (state) => { + if (!state) return; + const known = new Set(state.items.map(i => i.id)); + const missing = DEFAULT_SIDEBAR_ITEMS.filter(i => !known.has(i.id)); + if (missing.length > 0) { + state.items = [...state.items, ...missing]; + } + }, + } ) ); diff --git a/src/styles/components.css b/src/styles/components.css index 5c3fa0d2..97bf49a9 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1217,6 +1217,7 @@ /* ─ Tracklist ─ */ .tracklist { padding: 0 var(--space-6) var(--space-6); + contain: layout; } .col-center { @@ -1225,7 +1226,6 @@ .tracklist-header { display: grid; - grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px; gap: var(--space-3); align-items: center; padding: var(--space-2) var(--space-3); @@ -1238,13 +1238,8 @@ margin-bottom: var(--space-2); } -.tracklist-header.tracklist-va { - grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px; -} - .track-row { display: grid; - grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px; gap: var(--space-3); align-items: center; padding: var(--space-2) var(--space-3); @@ -1252,65 +1247,156 @@ cursor: pointer; transition: background var(--transition-fast); user-select: none; -} - -.track-row.track-row-va { - grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px; + contain: layout style; + overflow: hidden; } .tracklist-total { display: grid; - grid-template-columns: 60px minmax(100px, 3fr) 70px 80px 60px 120px; - gap: var(--space-3); + align-items: center; border-top: 1px solid var(--border-subtle); padding: var(--space-2) var(--space-3); margin-top: var(--space-1); } -.tracklist-total.tracklist-va { - grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px; -} - .tracklist-total-label { - grid-column: 1 / 5; - text-align: right; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; + text-align: right; } .tracklist-total-value { - grid-column: 5 / 6; - text-align: center; font-size: 13px; font-weight: 600; color: var(--text-secondary); font-variant-numeric: tabular-nums; + text-align: center; } -.tracklist-total.tracklist-va .tracklist-total-label { - grid-column: 1 / 6; +/* ── Column resize handle ── */ +/* Sits at the right edge of each header cell, fully inside the cell bounds */ +.col-resize-handle { + position: absolute; + right: -4px; + top: 0; + bottom: 0; + width: 8px; + cursor: col-resize; + z-index: 2; } -.tracklist-total.tracklist-va .tracklist-total-value { - grid-column: 6 / 7; +.col-resize-handle::after { + content: ''; + position: absolute; + left: 3px; + top: 20%; + bottom: 20%; + width: 2px; + border-radius: 1px; + background: var(--ctp-surface1); + transition: background 0.15s; +} + +.col-resize-handle:hover::after, +.col-resize-handle:active::after { + background: var(--accent); +} + +/* ── Column visibility picker ── */ +.tracklist-col-picker { + position: absolute; + top: 0; + right: 0; + height: 100%; + display: flex; + align-items: center; + padding-right: 4px; +} + +.tracklist-col-picker-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + opacity: 0.5; + transition: opacity 0.15s, background 0.15s; +} + +.tracklist-col-picker-btn:hover { + opacity: 1; + background: var(--ctp-surface0); + color: var(--text-primary); +} + +.tracklist-col-picker-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + background: var(--bg-card); + border: 1px solid var(--ctp-surface1); + border-radius: var(--radius-md); + padding: 6px; + min-width: 160px; + z-index: 200; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35); +} + +.tracklist-col-picker-label { + font-size: 10px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.07em; + padding: 4px 8px 6px; +} + +.tracklist-col-picker-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 5px 8px; + border-radius: var(--radius-sm); + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + text-align: left; + transition: background 0.1s; +} + +.tracklist-col-picker-item:hover { + background: var(--ctp-surface0); + color: var(--text-primary); +} + +.tracklist-col-picker-item.active { + color: var(--accent); +} + +.tracklist-col-picker-check { + width: 16px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); } /* ── Playlist tracklist variant — adds delete column ── */ .tracklist-header.tracklist-playlist, -.track-row.tracklist-playlist, -.tracklist-total.tracklist-playlist { - grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 120px 36px; -} - -.tracklist-total.tracklist-playlist .tracklist-total-label { - grid-column: 1 / 6; -} - -.tracklist-total.tracklist-playlist .tracklist-total-value { - grid-column: 6 / 7; +.track-row.tracklist-playlist { + grid-template-columns: 60px minmax(80px, 1.5fr) minmax(60px, 1fr) 70px 80px 60px 80px 80px 36px; } /* Delete button in playlist row */ @@ -1392,7 +1478,7 @@ } .track-num .bulk-check { position: absolute; - left: 6px; + left: 0; opacity: 0; pointer-events: none; transition: opacity 0.1s; @@ -1406,6 +1492,44 @@ pointer-events: auto; } +/* Default: show track number */ +.track-num-number { + font-size: 13px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; + text-align: center; +} + +/* Play icon: hidden by default */ +.track-num-play { + display: none; + align-items: center; + color: var(--text-primary); +} + +/* Eq-bars wrapper */ +.track-num-eq { + display: flex; + align-items: center; + justify-content: flex-end; +} + +/* ── Hover (non-active row): hide number, show play icon ── */ +.track-row:hover .track-num-number { display: none; } +.track-row:hover .track-num-play { display: flex; } + +/* ── Active (currently playing): hide number, show eq-bars ── */ +.track-num.track-num-active .track-num-number { display: none; } +.track-num.track-num-active .track-num-number { color: var(--accent); } /* kept for when visible */ + +/* ── Active + hover: hide eq-bars, show play icon ── */ +.track-row:hover .track-num.track-num-active .track-num-eq { display: none; } +.track-row:hover .track-num.track-num-active .track-num-play { display: flex; } + +/* Paused state: show play icon statically (no hover needed) */ +.track-num.track-num-paused .track-num-play { display: flex; opacity: 0.5; } +.track-row:hover .track-num.track-num-paused .track-num-play { opacity: 1; } + /* Equalizer bars — shown for the currently playing track */ .eq-bars { display: flex; @@ -1478,11 +1602,6 @@ font-size: 16px; } -.track-num { - font-size: 13px; - color: var(--text-muted); - font-variant-numeric: tabular-nums; -} .track-info { min-width: 0; @@ -1510,6 +1629,11 @@ text-overflow: ellipsis; } +.track-artist-link:hover { + color: var(--accent); + text-decoration: underline; +} + .track-codec { display: block; font-size: 10px; @@ -1542,6 +1666,14 @@ margin-top: 0; } +.track-genre { + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .track-star-cell { display: flex; justify-content: center; @@ -2281,10 +2413,18 @@ box-shadow: var(--shadow-sm); } +.toggle-switch:hover .toggle-track { + background: var(--ctp-overlay0); +} + .toggle-switch input:checked+.toggle-track { background: var(--accent); } +.toggle-switch:hover input:checked+.toggle-track { + filter: brightness(1.15); +} + .toggle-switch input:checked+.toggle-track::before { transform: translateX(20px); } @@ -2507,10 +2647,10 @@ /* ── Blurred background ── */ .fs-bg { position: absolute; - inset: -30%; + inset: -20%; background-size: cover; background-position: center 20%; - filter: blur(6px) brightness(0.25) saturate(1.6); + filter: blur(4px) brightness(0.25) saturate(1.5); animation: ken-burns 120s linear infinite; z-index: 0; will-change: transform; @@ -2561,6 +2701,7 @@ gap: 18px; width: min(440px, 88vw); padding: 16px 0; + will-change: transform; } /* Artist name — above cover */ @@ -2584,6 +2725,7 @@ box-shadow: none; flex-shrink: 0; animation: cover-breathe 9s ease-in-out infinite; + will-change: transform; } .fs-cover { @@ -4354,6 +4496,134 @@ color: var(--text-muted); white-space: nowrap; } + +/* Pre-gain row */ +.eq-pregain-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px 10px; + border-top: 1px solid var(--border-subtle); +} + +.eq-pregain-label { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + min-width: 72px; +} + +.eq-pregain-slider { + flex: 1; + accent-color: var(--accent); + height: 4px; + cursor: pointer; +} + +.eq-pregain-val { + font-size: 11px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + min-width: 52px; + text-align: right; +} + +/* AutoEQ section */ +.eq-autoeq-section { + border: 1px solid var(--border-subtle); + border-radius: 8px; + overflow: hidden; +} + +.eq-autoeq-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 8px 12px; + background: var(--bg-card); + border: none; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + text-align: left; +} + +.eq-autoeq-toggle:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.eq-autoeq-toggle span { + flex: 1; +} + +.eq-autoeq-body { + padding: 10px 12px 12px; + background: var(--bg-app); + display: flex; + flex-direction: column; + gap: 8px; +} + +.eq-autoeq-search-row { + display: flex; + align-items: center; + gap: 6px; +} + +.eq-autoeq-status { + font-size: 11px; + color: var(--text-muted); + padding: 2px 0; +} + +.eq-autoeq-error { + color: var(--danger); +} + +.eq-autoeq-applied { + color: var(--accent); +} + +.eq-autoeq-results { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 180px; + overflow-y: auto; +} + +.eq-autoeq-result-btn { + text-align: left; + padding: 5px 10px; + border-radius: 5px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; +} + +.eq-autoeq-result-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.eq-autoeq-result-btn { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.eq-autoeq-result-source { + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; +} + /* ─── Changelog ────────────────────────────────────────────────────────────── */ .changelog-list { @@ -4668,6 +4938,32 @@ to { background: #ff2222; } } +/* ─ Internet Radio ─ */ +.radio-card-active { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.radio-live-overlay { + position: absolute; + top: 6px; + left: 6px; + z-index: 3; + pointer-events: none; +} + +.radio-live-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + background: #e03030; + color: #fff; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.06em; + line-height: 1.4; +} + /* ─ Bulk Select ─ */ .bulk-action-bar { display: flex; diff --git a/src/styles/layout.css b/src/styles/layout.css index f0e4dac3..9b6672e7 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -1,5 +1,14 @@ /* ─── Psysonic App Shell ─── */ +*, *::before, *::after { + user-select: none; +} + +/* Allow text selection only where the user needs to copy content */ +.artist-bio, [data-selectable] { + user-select: text; +} + .app-shell { position: relative; display: grid; @@ -141,18 +150,17 @@ opacity: 1; } -/* Now Playing nav link */ +/* Now Playing nav link — subtly accented but not permanently "active"-looking */ .nav-link-nowplaying { color: var(--accent); - background: var(--accent-dim); font-weight: 600; } .nav-link-nowplaying:hover { - background: color-mix(in srgb, var(--accent) 20%, transparent); + background: var(--bg-hover); color: var(--accent); } .nav-link-nowplaying.active { - background: color-mix(in srgb, var(--accent) 22%, transparent); + background: var(--accent-dim); color: var(--accent); } .nav-link-nowplaying svg { diff --git a/src/styles/theme.css b/src/styles/theme.css index 971790e3..dfcfb974 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -3670,10 +3670,12 @@ body.psy-dragging * { .btn-surface { background: var(--bg-card); color: var(--text-primary); + border: 1px solid var(--ctp-surface1); } .btn-surface:hover { background: var(--bg-hover); + border-color: var(--ctp-overlay0); } /* ─── Input ─── */