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>