mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-08 13:23:16 +02:00
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>
This commit is contained in:
@@ -27,6 +27,7 @@ export const PlayerbarWaveform = () => {
|
|||||||
const audioElementRef = useRef<HTMLAudioElement>(document.createElement('audio'));
|
const audioElementRef = useRef<HTMLAudioElement>(document.createElement('audio'));
|
||||||
const { mediaSeekToTimestamp } = usePlayer();
|
const { mediaSeekToTimestamp } = usePlayer();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [tooltipPosition, setTooltipPosition] = useState<null | { x: number; y: number }>(null);
|
const [tooltipPosition, setTooltipPosition] = useState<null | { x: number; y: number }>(null);
|
||||||
const [tooltipValue, setTooltipValue] = useState(0);
|
const [tooltipValue, setTooltipValue] = useState(0);
|
||||||
@@ -77,14 +78,27 @@ export const PlayerbarWaveform = () => {
|
|||||||
// Reset loading state when stream URL changes and ensure media is muted
|
// Reset loading state when stream URL changes and ensure media is muted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setHasError(false);
|
||||||
}, [streamUrl]);
|
}, [streamUrl]);
|
||||||
|
|
||||||
// Handle waveform ready state
|
// Handle waveform ready state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!wavesurfer || !streamUrl) return;
|
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 = () => {
|
const handleReady = () => {
|
||||||
|
if (cancelled || !loadStarted) return;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setHasError(false);
|
||||||
const mediaElement = wavesurfer.getMediaElement();
|
const mediaElement = wavesurfer.getMediaElement();
|
||||||
if (mediaElement) {
|
if (mediaElement) {
|
||||||
mediaElement.muted = true;
|
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('ready', handleReady);
|
||||||
|
wavesurfer.on('error', handleError);
|
||||||
|
|
||||||
const waveformTimeout = setTimeout(
|
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,
|
playerbarSlider?.loadingDelay ? playerbarSlider.loadingDelay * 1000 : 2000,
|
||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
wavesurfer.un('ready', handleReady);
|
wavesurfer.un('ready', handleReady);
|
||||||
|
wavesurfer.un('error', handleError);
|
||||||
clearTimeout(waveformTimeout);
|
clearTimeout(waveformTimeout);
|
||||||
};
|
};
|
||||||
}, [wavesurfer, streamUrl, playerbarSlider.loadingDelay]);
|
}, [wavesurfer, streamUrl, playerbarSlider.loadingDelay]);
|
||||||
@@ -349,14 +386,14 @@ export const PlayerbarWaveform = () => {
|
|||||||
style={{ position: 'relative' }}
|
style={{ position: 'relative' }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ opacity: isLoading ? 0 : 1 }}
|
animate={{ opacity: isLoading || hasError ? 0 : 1 }}
|
||||||
className={styles.waveform}
|
className={styles.waveform}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
/>
|
/>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isLoading && (
|
{(isLoading || hasError) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
|||||||
Reference in New Issue
Block a user