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({