fix(player): prevent streaming seek UI freezes and progress snapbacks (#236)

* fix(player): prevent streaming seek UI freezes and progress snapbacks

Move blocking seek work off the command path with a bounded timeout, switch fullscreen/mobile seek bars to commit-on-release, and stabilize fallback progress state so rapid early seeks on streamed tracks do not freeze the UI or jump progress to zero.

* fix(player): avoid second-seek lockups and progress flicker

Add a short lock timeout for audio seek state access to prevent UI stalls when rapid seeks overlap, and keep waveform progress stable right after seek commit to eliminate brief visual snapbacks.

* fix(player): harden seek recovery and reduce stream log noise

Keep seek fallback stable during delayed backend responses to prevent occasional resets to track start, and remove per-read stream blocking logs so normal playback diagnostics stay readable.

---------

Co-authored-by: Maxim Isaev <im@friclub.ru>
This commit is contained in:
Frank Stellmacher
2026-04-21 12:10:26 +02:00
committed by GitHub
parent c61bcacd0d
commit fa21379dbb
5 changed files with 292 additions and 72 deletions
+35 -2
View File
@@ -428,6 +428,28 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
const playedRef = useRef<HTMLDivElement>(null);
const bufRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const isDraggingRef = useRef(false);
const pendingSeekRef = useRef<number | null>(null);
const previewSeek = useCallback((progress: number) => {
const s = usePlayerStore.getState();
const p = Math.max(0, Math.min(1, progress));
pendingSeekRef.current = p;
if (timeRef.current) {
const previewTime = duration > 0 ? p * duration : s.currentTime;
timeRef.current.textContent = formatTime(previewTime);
}
if (playedRef.current) playedRef.current.style.width = `${p * 100}%`;
if (bufRef.current) bufRef.current.style.width = `${Math.max(p * 100, s.buffered * 100)}%`;
if (inputRef.current) inputRef.current.value = String(p);
}, [duration]);
const commitSeek = useCallback(() => {
const pending = pendingSeekRef.current;
if (pending === null) return;
pendingSeekRef.current = null;
seek(pending);
}, [seek]);
useEffect(() => {
const s = usePlayerStore.getState();
@@ -438,6 +460,7 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
if (inputRef.current) inputRef.current.value = String(s.progress);
return usePlayerStore.subscribe(state => {
if (isDraggingRef.current) return;
const p = state.progress * 100;
if (timeRef.current) timeRef.current.textContent = formatTime(state.currentTime);
if (playedRef.current) playedRef.current.style.width = `${p}%`;
@@ -447,8 +470,10 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
}, []);
const handleSeek = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => seek(parseFloat(e.target.value)),
[seek]
(e: React.ChangeEvent<HTMLInputElement>) => {
previewSeek(parseFloat(e.target.value));
},
[previewSeek]
);
return (
@@ -466,6 +491,14 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) {
type="range" min={0} max={1} step={0.001}
defaultValue={0}
onChange={handleSeek}
onMouseDown={() => { isDraggingRef.current = true; }}
onMouseUp={() => { isDraggingRef.current = false; commitSeek(); }}
onTouchStart={() => { isDraggingRef.current = true; }}
onTouchEnd={() => { isDraggingRef.current = false; commitSeek(); }}
onPointerDown={() => { isDraggingRef.current = true; }}
onPointerUp={() => { isDraggingRef.current = false; commitSeek(); }}
onKeyUp={commitSeek}
onBlur={() => { isDraggingRef.current = false; commitSeek(); }}
aria-label="seek"
/>
</div>
+31 -7
View File
@@ -204,14 +204,21 @@ export default function MobilePlayerView() {
// Scrubber touch/mouse drag
const scrubberRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const pendingSeekRef = useRef<number | null>(null);
const [previewProgress, setPreviewProgress] = useState<number | null>(null);
const setPreviewSeek = useCallback((pct: number) => {
pendingSeekRef.current = pct;
setPreviewProgress(pct);
}, []);
const seekFromX = useCallback((clientX: number) => {
const el = scrubberRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
seek(pct);
}, [seek]);
setPreviewSeek(pct);
}, [setPreviewSeek]);
const onScrubStart = useCallback((clientX: number) => {
isDragging.current = true;
@@ -224,7 +231,14 @@ export default function MobilePlayerView() {
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
seekFromX(clientX);
};
const onEnd = () => { isDragging.current = false; };
const onEnd = () => {
if (!isDragging.current) return;
isDragging.current = false;
const pending = pendingSeekRef.current;
pendingSeekRef.current = null;
setPreviewProgress(null);
if (pending !== null) seek(pending);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onEnd);
@@ -238,6 +252,11 @@ export default function MobilePlayerView() {
};
}, [seekFromX]);
useEffect(() => {
pendingSeekRef.current = null;
setPreviewProgress(null);
}, [currentTrack?.id]);
// Drawers
const [showQueue, setShowQueue] = useState(false);
const [showLyrics, setShowLyrics] = useState(false);
@@ -264,6 +283,11 @@ export default function MobilePlayerView() {
const bgStyle: CSSProperties = {
background: `radial-gradient(ellipse 160% 55% at 50% 20%, rgba(${accentColor}, 0.38) 0%, var(--bg-app) 65%)`,
};
const effectiveProgress = previewProgress ?? progress;
const effectiveTime =
previewProgress !== null && duration > 0
? previewProgress * duration
: currentTime;
return (
<div className="mp-view" style={bgStyle}>
@@ -328,12 +352,12 @@ export default function MobilePlayerView() {
onTouchStart={e => onScrubStart(e.touches[0].clientX)}
>
<div className="mp-scrubber-bg" />
<div className="mp-scrubber-fill" style={{ width: `${progress * 100}%` }} />
<div className="mp-scrubber-thumb" style={{ left: `${progress * 100}%` }} />
<div className="mp-scrubber-fill" style={{ width: `${effectiveProgress * 100}%` }} />
<div className="mp-scrubber-thumb" style={{ left: `${effectiveProgress * 100}%` }} />
</div>
<div className="mp-scrubber-times">
<span>{formatTime(currentTime)}</span>
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
<span>{formatTime(effectiveTime)}</span>
<span>-{formatTime(Math.max(0, duration - effectiveTime))}</span>
</div>
</div>
+14
View File
@@ -801,6 +801,9 @@ interface Props {
}
export default function WaveformSeek({ trackId }: Props) {
const SEEK_COMMIT_GUARD_MS = 900;
const SEEK_COMMIT_MIN_HOLD_MS = 320;
const SEEK_COMMIT_PROGRESS_EPS = 0.02;
const canvasRef = useRef<HTMLCanvasElement>(null);
const heightsRef = useRef<Float32Array | null>(null);
const progressRef = useRef(usePlayerStore.getState().progress);
@@ -835,6 +838,15 @@ export default function WaveformSeek({ trackId }: Props) {
// 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;
const pendingCommit = pendingCommittedSeekRef.current;
if (pendingCommit) {
const ageMs = Date.now() - pendingCommit.setAtMs;
if (ageMs < SEEK_COMMIT_MIN_HOLD_MS) return;
const matched = Math.abs(state.progress - pendingCommit.fraction) <= SEEK_COMMIT_PROGRESS_EPS;
const expired = ageMs > SEEK_COMMIT_GUARD_MS;
if (!matched && !expired) return;
pendingCommittedSeekRef.current = null;
}
progressRef.current = state.progress;
bufferedRef.current = state.buffered;
if (!ANIMATED_STYLES.has(styleRef.current)) {
@@ -894,6 +906,7 @@ export default function WaveformSeek({ trackId }: Props) {
const seekRef = useRef(seek);
seekRef.current = seek;
const pendingSeekRef = useRef<number | null>(null);
const pendingCommittedSeekRef = useRef<{ fraction: number; setAtMs: number } | null>(null);
// Preview a 01 fraction while dragging: draw immediately for 1:1
// responsiveness; the actual seek is committed on mouseup.
@@ -910,6 +923,7 @@ export default function WaveformSeek({ trackId }: Props) {
const fraction = pendingSeekRef.current;
if (fraction === null) return;
pendingSeekRef.current = null;
pendingCommittedSeekRef.current = { fraction, setAtMs: Date.now() };
seekRef.current(fraction);
};