mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
committed by
GitHub
parent
c61bcacd0d
commit
fa21379dbb
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 0–1 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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user