` wrapper; positioned via `left: ${hoverPct * 100}%`. Hidden when no track is loaded or mouse has left the canvas.
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
- **Random Mix hover**: Row uses `.context-active` CSS class when a context menu is open for that song. `contextMenuSongId` state cleared via `useEffect` when `contextMenu.isOpen` becomes false.
- **Favorites songs**: Full tracklist layout matching AlbumDetail — `track-row-va` grid with `#`, Title, Artist, Duration columns and a header row. Artist name clickable → artist page. "Add all to queue" button (`btn btn-surface`) next to the section title sends all favorited songs to the queue.
@@ -244,4 +246,4 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
- **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact.
- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors.
-- **Version**: 1.13.0
+- **Version**: 1.14.0
diff --git a/README.md b/README.md
index ab9b3ef5..ed9a2fab 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,6 @@ Designed specifically for users hosting their own music via Navidrome or other S
- [x] Font picker (10 UI fonts)
### 📋 Planned
-- [ ] FLAC seeking fix
- [ ] General UI polish & visual refinement
- [ ] More languages
@@ -71,7 +70,7 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ● Known Limitations
- **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly.
-- **FLAC seeking**: Jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected. This is a known issue and will be investigated.
+- **FLAC seeking**: Seeking in FLAC files requires an embedded SEEKTABLE metadata block. Files encoded without one cannot be seeked — clicking the waveform has no effect. Most modern encoders include a SEEKTABLE by default. You can add one retroactively with `metaflac --add-seekpoint=10s *.flac`.
## 📥 Installation
diff --git a/package.json b/package.json
index 9fe680c2..e3ea2b3a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "psysonic",
- "version": "1.13.0",
+ "version": "1.14.0",
"private": true,
"scripts": {
"dev": "vite",
diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD
index 9e3c9c82..ee32502c 100644
--- a/packages/aur/PKGBUILD
+++ b/packages/aur/PKGBUILD
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic
pkgname=psysonic
-pkgver=1.13.0
+pkgver=1.14.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 dd4f2660..dcea262b 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -3139,7 +3139,7 @@ dependencies = [
[[package]]
name = "psysonic"
-version = "1.12.0"
+version = "1.14.0"
dependencies = [
"biquad",
"md5",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 3120f4ff..a554895f 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
-version = "1.12.0"
+version = "1.14.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs
index 872063bd..75c988d0 100644
--- a/src-tauri/src/audio.rs
+++ b/src-tauri/src/audio.rs
@@ -380,11 +380,17 @@ impl> Source for CountingSource {
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn total_duration(&self) -> Option { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
- // Reset counter to the sought position in samples.
- let samples = (pos.as_secs_f64() * self.inner.sample_rate() as f64
- * self.inner.channels() as f64) as u64;
- self.counter.store(samples, Ordering::Relaxed);
- self.inner.try_seek(pos)
+ // Reset counter only after confirming the inner seek succeeded.
+ // If we reset first and the seek fails, the counter ends up at the
+ // new position while the decoder is still at the old one — causing
+ // a permanent desync between displayed time and actual audio.
+ let result = self.inner.try_seek(pos);
+ if result.is_ok() {
+ let samples = (pos.as_secs_f64() * self.inner.sample_rate() as f64
+ * self.inner.channels() as f64) as u64;
+ self.counter.store(samples, Ordering::Relaxed);
+ }
+ result
}
}
@@ -746,6 +752,12 @@ async fn fetch_data(
Ok(Some(data))
}
+/// -1 dB headroom applied at full scale to prevent inter-sample clipping.
+/// Modern masters are often at 0 dBFS; the EQ biquad chain and resampler
+/// can produce inter-sample peaks slightly above ±1.0 → audible distortion.
+/// 10^(-1/20) ≈ 0.891 — inaudible volume difference, eliminates clipping.
+const MASTER_HEADROOM: f32 = 0.891_254;
+
fn compute_gain(
replay_gain_db: Option,
replay_gain_peak: Option,
@@ -756,7 +768,7 @@ fn compute_gain(
.unwrap_or(1.0);
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
let gain_linear = gain_linear.min(1.0 / peak);
- let effective = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0);
+ let effective = (volume.clamp(0.0, 1.0) * gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
(gain_linear, effective)
}
@@ -1280,7 +1292,7 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
}
}
- // Seeking far back invalidates any pending gapless chain.
+ // Seeking back invalidates any pending gapless chain.
let cur_pos = {
let cur = state.current.lock().unwrap();
cur.position()
@@ -1290,15 +1302,17 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
}
let mut cur = state.current.lock().unwrap();
- if let Some(sink) = &cur.sink {
- sink.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
- .map_err(|e: rodio::source::SeekError| e.to_string())?;
- if cur.paused_at.is_some() {
- cur.paused_at = Some(seconds);
- } else {
- cur.seek_offset = seconds;
- cur.play_started = Some(Instant::now());
- }
+ if cur.sink.is_none() { return Ok(()); }
+
+ cur.sink.as_ref().unwrap()
+ .try_seek(Duration::from_secs_f64(seconds.max(0.0)))
+ .map_err(|e| e.to_string())?;
+
+ if cur.paused_at.is_some() {
+ cur.paused_at = Some(seconds);
+ } else {
+ cur.seek_offset = seconds;
+ cur.play_started = Some(Instant::now());
}
Ok(())
}
@@ -1308,7 +1322,7 @@ pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
- sink.set_volume((cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0));
+ sink.set_volume((cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0));
}
}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 002e0daf..f8fb7b2a 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.13.0",
+ "version": "1.14.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx
index 8b76f630..02b0663c 100644
--- a/src/components/AlbumHeader.tsx
+++ b/src/components/AlbumHeader.tsx
@@ -103,6 +103,7 @@ export default function AlbumHeader({
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
+ const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / ');
return (
<>
@@ -159,6 +160,7 @@ export default function AlbumHeader({
{info.genre && · {info.genre}}
· {songs.length} Tracks
· {formatDuration(totalDuration)}
+ {formatLabel && · {formatLabel}}
{info.recordLabel && (
<>
·
diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx
index 30246197..1cee050d 100644
--- a/src/components/Hero.tsx
+++ b/src/components/Hero.tsx
@@ -80,6 +80,18 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const album = albums[activeIdx] ?? null;
+ // Lazily fetch format label for the currently-visible album (cached by id)
+ const [albumFormats, setAlbumFormats] = useState>({});
+ useEffect(() => {
+ if (!album || albumFormats[album.id] !== undefined) return;
+ getAlbum(album.id).then(data => {
+ const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))];
+ setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') }));
+ }).catch(() => {
+ setAlbumFormats(prev => ({ ...prev, [album.id]: '' }));
+ });
+ }, [album?.id]);
+
// Resolve background URL via cache
const bgRawUrl = album?.coverArt ? buildCoverArtUrl(album.coverArt, 800) : '';
const bgCacheKey = album?.coverArt ? coverArtCacheKey(album.coverArt, 800) : '';
@@ -120,6 +132,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
{album.year && {album.year}}
{album.genre && {album.genre}}
{album.songCount && {album.songCount} Tracks}
+ {albumFormats[album.id] && {albumFormats[album.id]}}