From ccf6e6c886e6b0135edd7704bef34deab6e95e3f Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Mon, 20 Apr 2026 17:28:20 +0200 Subject: [PATCH] fix(seekbar): commit seek on mouseup + ignore progress ticks while dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the WaveformSeek UX changes from PR #219 (original fix by @cucadmuh) onto test/seekable-streaming: - Split previewFraction (during drag) / commitSeek (on mouseup) so audio_seek fires once per gesture instead of on every mousemove. - Subscribe-effect skips external progress updates while isDragging — prevents cursor flicker from streaming/recovery ticks fighting the dragged position. - Clear seekTarget in seek() catch branch so a failed seek doesn't permanently block progress updates. Skips the byte-restart fast-path from #219: would defeat RangedHttpSource's direct seek by restarting the stream after 450ms. Co-Authored-By: cucadmuh Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/WaveformSeek.tsx | 27 +++++++++++++++++++++------ src/store/playerStore.ts | 3 +++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index dea171db..26b067e7 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -832,6 +832,9 @@ export default function WaveformSeek({ trackId }: Props) { useEffect(() => { return usePlayerStore.subscribe((state, prev) => { if (state.progress === prev.progress && state.buffered === prev.buffered) return; + // While user drags, keep the local preview stable. External progress ticks + // during streaming/recovery would otherwise fight the cursor and flicker. + if (isDragging.current) return; progressRef.current = state.progress; bufferedRef.current = state.buffered; if (!ANIMATED_STYLES.has(styleRef.current)) { @@ -890,15 +893,23 @@ export default function WaveformSeek({ trackId }: Props) { trackIdRef.current = trackId; const seekRef = useRef(seek); seekRef.current = seek; + const pendingSeekRef = useRef(null); - // Seek to a 0–1 fraction: draw immediately for 1:1 responsiveness, then - // let the store + Rust catch up asynchronously. - const seekToFraction = (fraction: number) => { + // Preview a 0–1 fraction while dragging: draw immediately for 1:1 + // responsiveness; the actual seek is committed on mouseup. + const previewFraction = (fraction: number) => { progressRef.current = fraction; + pendingSeekRef.current = fraction; const canvas = canvasRef.current; if (canvas && !ANIMATED_STYLES.has(styleRef.current)) { drawSeekbar(canvas, styleRef.current, heightsRef.current, fraction, bufferedRef.current); } + }; + + const commitSeek = () => { + const fraction = pendingSeekRef.current; + if (fraction === null) return; + pendingSeekRef.current = null; seekRef.current(fraction); }; @@ -907,10 +918,14 @@ export default function WaveformSeek({ trackId }: Props) { const canvas = canvasRef.current; if (!canvas || !trackIdRef.current) return; const rect = canvas.getBoundingClientRect(); - seekToFraction(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))); + previewFraction(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))); }; const onMove = (e: MouseEvent) => { if (isDragging.current) seekFromX(e.clientX); }; - const onUp = () => { isDragging.current = false; }; + const onUp = () => { + if (!isDragging.current) return; + isDragging.current = false; + commitSeek(); + }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { @@ -935,7 +950,7 @@ export default function WaveformSeek({ trackId }: Props) { onMouseDown={e => { isDragging.current = true; const rect = e.currentTarget.getBoundingClientRect(); - seekToFraction(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))); + previewFraction(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))); }} onMouseMove={e => { if (!trackId) return; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 268331be..708abe16 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -1427,6 +1427,9 @@ export const usePlayerStore = create()( seekDebounce = null; seekTarget = time; invoke('audio_seek', { seconds: time }).catch((err: unknown) => { + // Release the progress-tick guard so the UI doesn't freeze + // waiting for a target the engine will never reach. + seekTarget = null; const msg = String(err ?? ''); if (!msg.includes('not seekable')) { console.error(err);