fix(preview): sync audio start, ring animation, and download timeout (#423)

* 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) <noreply@anthropic.com>

* docs(changelog): add preview audio sync fix for PR #423

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frank Stellmacher
2026-05-02 14:44:49 +02:00
committed by GitHub
parent 9cc74a7f88
commit 297c9f1125
10 changed files with 104 additions and 34 deletions
+20 -5
View File
@@ -21,6 +21,15 @@ interface PreviewState {
elapsed: number;
/** Total preview window in seconds (echoes the duration_sec arg). */
duration: number;
/**
* True only after the engine has emitted `audio:preview-start` for the
* current `previewingId` — i.e. audio is actually playing. Drives the
* progress-ring animation so the ring doesn't run ahead of the speaker
* during the engine's download/decode/seek warmup. Reset to false on every
* `startPreview` call so a switch from track A to track B doesn't carry
* over A's animation state.
*/
audioStarted: boolean;
startPreview: (song: { id: string; title: string; artist: string; coverArt?: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>;
stopPreview: () => Promise<void>;
@@ -40,6 +49,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
previewingTrack: null,
elapsed: 0,
duration: 30,
audioStarted: false,
startPreview: async (song, location) => {
const auth = useAuthStore.getState();
@@ -77,6 +87,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
previewingTrack: { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt },
elapsed: 0,
duration: previewDuration,
audioStarted: false,
});
try {
@@ -90,7 +101,7 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
} catch (e) {
// Roll back optimistic state on failure.
if (get().previewingId === song.id) {
set({ previewingId: null, previewingTrack: null, elapsed: 0 });
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
}
throw e;
}
@@ -102,15 +113,19 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
await invoke('audio_preview_stop');
} catch {
/* engine will emit preview-end anyway; clear locally as fallback */
set({ previewingId: null, previewingTrack: null, elapsed: 0 });
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
}
},
_onStart: (id) => {
if (get().previewingId !== id) {
const current = get().previewingId;
if (current !== id) {
// Engine fired start for an id we didn't track locally — keep id but
// leave previewingTrack as-is (the caller's startPreview() set it).
set({ previewingId: id, elapsed: 0 });
set({ previewingId: id, elapsed: 0, audioStarted: true });
} else {
// Audio is now actually playing — unblock the progress-ring animation.
set({ audioStarted: true });
}
},
@@ -121,6 +136,6 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
_onEnd: (id) => {
if (get().previewingId !== id) return;
set({ previewingId: null, previewingTrack: null, elapsed: 0 });
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
},
}));