From 1ab01086546f95e53053ad140c97d32d56d3e042 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 23 Jul 2026 22:18:58 -0700 Subject: [PATCH] fix: keep a seek control when the waveform fails to load (#2257) The playerbar seek/progress bar could disappear entirely after switching tracks, recoverable only by restarting the app. The waveform component rendered the fallback `PlayerbarSeekSlider` only while `isLoading` was true and cleared that flag on any wavesurfer `ready` event, with no handling for a failed or superseded load: `wavesurfer.load()` had no `.catch()` and there was no `error` listener, so a load failure left an empty waveform canvas and no seek control. Track an explicit `hasError` state and render the fallback slider whenever the waveform `isLoading || hasError`, hiding the empty canvas in that case, so a seek control is always present. Add an `error` listener and a `.catch()` on `load()` that ignore `AbortError` (the expected result of a rapid track switch) and surface only real failures. A per-load `loadStarted` guard, together with the existing cleanup, ignores `ready`/ `error` events from a superseded load so they cannot clear the loading state for the wrong track. Closes #2193 Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- .../player/components/playerbar-waveform.tsx | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/renderer/features/player/components/playerbar-waveform.tsx b/src/renderer/features/player/components/playerbar-waveform.tsx index 6c1a0d59e..31f28b26d 100644 --- a/src/renderer/features/player/components/playerbar-waveform.tsx +++ b/src/renderer/features/player/components/playerbar-waveform.tsx @@ -27,6 +27,7 @@ export const PlayerbarWaveform = () => { const audioElementRef = useRef(document.createElement('audio')); const { mediaSeekToTimestamp } = usePlayer(); const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); const [isDragging, setIsDragging] = useState(false); const [tooltipPosition, setTooltipPosition] = useState(null); const [tooltipValue, setTooltipValue] = useState(0); @@ -77,14 +78,27 @@ export const PlayerbarWaveform = () => { // Reset loading state when stream URL changes and ensure media is muted useEffect(() => { setIsLoading(true); + setHasError(false); }, [streamUrl]); // Handle waveform ready state useEffect(() => { if (!wavesurfer || !streamUrl) return; + // The wavesurfer instance is shared across stream URLs, and this + // effect subscribes before its (delayed) load actually starts. Guard + // against events that do not belong to this effect's own load: + // `cancelled` rejects events after the URL has moved on, and + // `loadStarted` rejects a still-in-flight previous load's `ready` + // (which would otherwise clear the loading state for the wrong + // track and hide the seek bar over an empty/stale waveform). + let cancelled = false; + let loadStarted = false; + const handleReady = () => { + if (cancelled || !loadStarted) return; setIsLoading(false); + setHasError(false); const mediaElement = wavesurfer.getMediaElement(); if (mediaElement) { mediaElement.muted = true; @@ -92,17 +106,40 @@ export const PlayerbarWaveform = () => { } }; + // A load failure previously left the waveform canvas empty with no + // seek control (the fallback slider only showed while loading), so + // the progress bar disappeared until the app was restarted. Surface + // real failures so the fallback slider is rendered again. AbortError + // is the expected outcome of a superseded load and is ignored. + const handleError = (error?: unknown) => { + if (cancelled || !loadStarted) return; + if (error instanceof Error && error.name === 'AbortError') return; + setIsLoading(false); + setHasError(true); + }; + wavesurfer.on('ready', handleReady); + wavesurfer.on('error', handleError); const waveformTimeout = setTimeout( () => { - wavesurfer.load(streamUrl); + if (cancelled) return; + loadStarted = true; + wavesurfer.load(streamUrl).catch((error: unknown) => { + if (cancelled || (error instanceof Error && error.name === 'AbortError')) { + return; + } + setIsLoading(false); + setHasError(true); + }); }, playerbarSlider?.loadingDelay ? playerbarSlider.loadingDelay * 1000 : 2000, ); return () => { + cancelled = true; wavesurfer.un('ready', handleReady); + wavesurfer.un('error', handleError); clearTimeout(waveformTimeout); }; }, [wavesurfer, streamUrl, playerbarSlider.loadingDelay]); @@ -349,14 +386,14 @@ export const PlayerbarWaveform = () => { style={{ position: 'relative' }} > - {isLoading && ( + {(isLoading || hasError) && (