From 297c9f11259e992a5df412677643a2da3aea98ba Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 2 May 2026 14:44:49 +0200 Subject: [PATCH] fix(preview): sync audio start, ring animation, and download timeout (#423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(preview): sync audio start, ring animation, and download timeout Three coupled fixes for the track-preview engine: 1. Audio sync. `Sink::try_seek` was running on a worker thread after `sink.append(source)`, so the sink began playing position 0 while the seek was still iterating to the mid-track target. With the 30 s `take_duration` cap counting wall-clock from append, audio could only become audible ~25% into the preview window. The seek now runs on the bare source before append, then `take_duration` wraps it — playback starts at the seek position with the cap measured from there. 2. Ring animation gating. The CSS progress-ring animation was bound to `is-previewing` (set on click), so the ring sprinted ahead of any download/decode/seek warmup and didn't reset cleanly when switching from one preview to another. Added an `audioStarted` flag in `previewStore` that flips on `audio:preview-start` from the engine; CSS animation is now gated on `audio-started` instead. `is-previewing` still drives tooltip/icon for instant click feedback. Same SVG is reused for a 25%-arc rotating loading spinner while waiting for audio, with a 150 ms delay so cached/short previews don't flash. 3. Download timeout. The shared `audio_http_client` caps at 30 s, which aborts mid-download on multi-hundred-MB uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB). The preview engine now builds a dedicated client with a 5 min timeout for the bytes fetch. Watchdog still bounds the playback window at 30 s once the audio actually starts. Touches `audio/preview.rs`, `previewStore.ts`, `components.css` plus the eight tracklist/player-bar components that render the preview button. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(changelog): add preview audio sync fix for PR #423 Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 ++ src-tauri/src/audio/preview.rs | 46 +++++++++++++++++-------------- src/components/AlbumTrackList.tsx | 3 +- src/components/PlayerBar.tsx | 3 +- src/pages/ArtistDetail.tsx | 3 +- src/pages/Favorites.tsx | 3 +- src/pages/PlaylistDetail.tsx | 5 ++-- src/pages/RandomMix.tsx | 5 ++-- src/store/previewStore.ts | 25 +++++++++++++---- src/styles/components.css | 43 ++++++++++++++++++++++++++++- 10 files changed, 104 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4425e7b0..67355b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,8 @@ The drag ghost shows a **trash** affordance only while the cursor is outside the - **Polish** *(PR [#397](https://github.com/Psychotoxical/psysonic/pull/397), by [@cucadmuh](https://github.com/cucadmuh))*: multiple branch-local interaction fixes around sidebar drag/drop behavior, Live dropdown layering, queue-resize handle behavior during scroll/overlay-scrollbar interaction, and now-playing narrow-layout stability. +- **Track preview audio in sync with progress ring; huge files no longer abort** *(Issue [#421](https://github.com/Psychotoxical/psysonic/issues/421), PR [#423](https://github.com/Psychotoxical/psysonic/pull/423), by [@Psychotoxical](https://github.com/Psychotoxical))*: Previews used to start audio about 25 % into the preview window on mid-track starts because `Sink::try_seek` ran in parallel with `sink.append` while the 30 s `take_duration` cap was already counting wall-clock from append. The seek now runs on the bare source before append, and the progress-ring animation only starts once the engine actually emits `audio:preview-start` — a small loading spinner is shown during the download/decode/seek warmup. The preview HTTP-client timeout was raised from 30 s to 5 min, so multi-hundred-megabyte Hi-Res files no longer abort the download mid-fetch. + ## [1.44.0] - 2026-04-29 diff --git a/src-tauri/src/audio/preview.rs b/src-tauri/src/audio/preview.rs index 86a18d2a..39d213e4 100644 --- a/src-tauri/src/audio/preview.rs +++ b/src-tauri/src/audio/preview.rs @@ -133,7 +133,19 @@ pub async fn audio_preview_play( } // ── Download ───────────────────────────────────────────────────────────── - let bytes = audio_http_client(&state) + // Dedicated client with a generous timeout. The shared `audio_http_client` + // caps at 30 s, which aborts mid-download on multi-hundred-megabyte + // uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need + // ~60–120 s on a typical home LAN. The watchdog (30 s wall-clock) still + // bounds how long the preview plays once the bytes are in memory, so a + // long download just means a longer "loading" spinner before audio starts. + let preview_http = reqwest::Client::builder() + .timeout(Duration::from_secs(300)) + .use_rustls_tls() + .user_agent(crate::subsonic_wire_user_agent()) + .build() + .unwrap_or_else(|_| audio_http_client(&state)); + let bytes = preview_http .get(&url) .send() .await @@ -163,12 +175,20 @@ pub async fn audio_preview_play( if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); } // ── Build source pipeline ──────────────────────────────────────────────── - // Minimal pipeline: f32 conversion + take_duration cap. No EQ, no crossfade, - // no ReplayGain — preview is intentionally simple. Volume is set on the Sink. + // Seek FIRST on the bare decoder, THEN cap with take_duration. Capping + // before the seek made take_duration's wall-clock counter tick from + // sink.append() while try_seek was still iterating the decoder to + // mid-track — the preview window consumed itself before audio actually + // arrived at the speaker (~25% of duration silent on FLAC/MP3 mid-track + // starts). Symphonia FLAC without SEEKTABLE may fail try_seek; preview + // then plays from 0, which is acceptable. + // No EQ / no crossfade / no ReplayGain — preview stays simple. + let mut source = decoder.convert_samples::(); + if start_sec > 0.5 { + let _ = source.try_seek(Duration::from_secs_f64(start_sec)); + } let dur = Duration::from_secs_f64(duration_sec.max(1.0).min(120.0)); - let source = decoder - .convert_samples::() - .take_duration(dur); + let source = source.take_duration(dur); // ── Build secondary sink on the existing OutputStream ──────────────────── let sink = Arc::new( @@ -178,20 +198,6 @@ pub async fn audio_preview_play( sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0)); sink.append(source); - // ── Best-effort seek to mid-track start position ───────────────────────── - // Symphonia FLAC without SEEKTABLE may fail here; preview then plays from 0 - // which is acceptable. Hard timeout 800 ms via a worker thread. - if start_sec > 0.5 { - let start_dur = Duration::from_secs_f64(start_sec); - let seek_sink = sink.clone(); - let (tx, rx) = std::sync::mpsc::channel::<()>(); - std::thread::spawn(move || { - seek_sink.try_seek(start_dur).ok(); - let _ = tx.send(()); - }); - let _ = rx.recv_timeout(Duration::from_millis(800)); - } - *state.preview_sink.lock().unwrap() = Some(sink.clone()); *state.preview_song_id.lock().unwrap() = Some(id.clone()); diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 0ffabb12..43e0a62a 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -119,6 +119,7 @@ const TrackRow = React.memo(function TrackRow({ const isActive = currentTrackId === song.id; // Primitive selector: row only re-renders when *this song's* preview state flips. const isPreviewing = usePreviewStore(s => s.previewingId === song.id); + const isPreviewAudioStarted = usePreviewStore(s => s.previewingId === song.id && s.audioStarted); const renderCell = (colDef: ColDef) => { const key = colDef.key as ColKey; @@ -156,7 +157,7 @@ const TrackRow = React.memo(function TrackRow({