mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +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
+150
-25
@@ -220,10 +220,88 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
// Target time of the last seek — blocks stale Rust progress ticks until the
|
||||
// engine has actually caught up to the new position.
|
||||
let seekTarget: number | null = null;
|
||||
let seekTargetSetAt = 0;
|
||||
const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000;
|
||||
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
||||
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let seekFallbackRetryStartedAt = 0;
|
||||
let seekFallbackRetryTarget: { trackId: string; seconds: number } | null = null;
|
||||
let seekFallbackTrackId: string | null = null;
|
||||
let seekFallbackRestartAt = 0;
|
||||
let seekFallbackVisualTarget: { trackId: string; seconds: number; setAtMs: number } | null = null;
|
||||
const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600;
|
||||
const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180;
|
||||
const SEEK_FALLBACK_RETRY_MAX_MS = 6000;
|
||||
|
||||
function setSeekTarget(seconds: number) {
|
||||
seekTarget = seconds;
|
||||
seekTargetSetAt = Date.now();
|
||||
}
|
||||
|
||||
function clearSeekTarget() {
|
||||
seekTarget = null;
|
||||
seekTargetSetAt = 0;
|
||||
}
|
||||
|
||||
function clearSeekFallbackRetry() {
|
||||
if (seekFallbackRetryTimer) {
|
||||
clearTimeout(seekFallbackRetryTimer);
|
||||
seekFallbackRetryTimer = null;
|
||||
}
|
||||
seekFallbackRetryStartedAt = 0;
|
||||
seekFallbackRetryTarget = null;
|
||||
}
|
||||
|
||||
function isRecoverableSeekError(msg: string): boolean {
|
||||
return msg.includes('not seekable')
|
||||
|| msg.includes('audio sink not ready')
|
||||
|| msg.includes('audio seek busy')
|
||||
|| msg.includes('audio seek timeout');
|
||||
}
|
||||
|
||||
function scheduleSeekFallbackRetry(trackId: string, seconds: number) {
|
||||
const now = Date.now();
|
||||
if (
|
||||
!seekFallbackRetryTarget
|
||||
|| seekFallbackRetryTarget.trackId !== trackId
|
||||
|| Math.abs(seekFallbackRetryTarget.seconds - seconds) > 0.25
|
||||
) {
|
||||
clearSeekFallbackRetry();
|
||||
seekFallbackRetryStartedAt = now;
|
||||
seekFallbackRetryTarget = { trackId, seconds };
|
||||
} else if (seekFallbackRetryStartedAt === 0) {
|
||||
seekFallbackRetryStartedAt = now;
|
||||
}
|
||||
if (seekFallbackRetryTimer) clearTimeout(seekFallbackRetryTimer);
|
||||
seekFallbackRetryTimer = setTimeout(() => {
|
||||
seekFallbackRetryTimer = null;
|
||||
const target = seekFallbackRetryTarget;
|
||||
const s = usePlayerStore.getState();
|
||||
if (!target || !s.currentTrack || s.currentTrack.id !== target.trackId) {
|
||||
clearSeekFallbackRetry();
|
||||
return;
|
||||
}
|
||||
if (Date.now() - seekFallbackRetryStartedAt > SEEK_FALLBACK_RETRY_MAX_MS) {
|
||||
clearSeekFallbackRetry();
|
||||
seekFallbackVisualTarget = null;
|
||||
return;
|
||||
}
|
||||
invoke('audio_seek', { seconds: target.seconds }).then(() => {
|
||||
setSeekTarget(target.seconds);
|
||||
seekFallbackVisualTarget = null;
|
||||
clearSeekFallbackRetry();
|
||||
}).catch((err: unknown) => {
|
||||
const msg = String(err ?? '');
|
||||
if (!isRecoverableSeekError(msg)) {
|
||||
console.error(err);
|
||||
seekFallbackVisualTarget = null;
|
||||
clearSeekFallbackRetry();
|
||||
return;
|
||||
}
|
||||
scheduleSeekFallbackRetry(target.trackId, target.seconds);
|
||||
});
|
||||
}, SEEK_FALLBACK_RETRY_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Guard against rapid double-click play/pause sending two state transitions
|
||||
// to the Rust backend before it has finished the previous one.
|
||||
@@ -388,17 +466,40 @@ function handleAudioProgress(current_time: number, duration: number) {
|
||||
// position before the seek takes effect. Block until current_time is
|
||||
// within 2 s of the requested target, then clear the guard.
|
||||
if (seekTarget !== null) {
|
||||
if (Math.abs(current_time - seekTarget) > 2.0) return;
|
||||
seekTarget = null;
|
||||
if (Math.abs(current_time - seekTarget) > 2.0) {
|
||||
// If a seek command hangs while streaming is stalled, do not freeze UI.
|
||||
if (Date.now() - seekTargetSetAt <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
|
||||
clearSeekTarget();
|
||||
} else {
|
||||
clearSeekTarget();
|
||||
}
|
||||
}
|
||||
|
||||
const store = usePlayerStore.getState();
|
||||
const track = store.currentTrack;
|
||||
if (!track) return;
|
||||
if (seekFallbackVisualTarget && seekFallbackVisualTarget.trackId !== track.id) {
|
||||
seekFallbackVisualTarget = null;
|
||||
}
|
||||
let displayTime = current_time;
|
||||
if (
|
||||
seekFallbackVisualTarget
|
||||
&& seekFallbackVisualTarget.trackId === track.id
|
||||
) {
|
||||
const nearTarget = Math.abs(current_time - seekFallbackVisualTarget.seconds) <= 2.0;
|
||||
if (nearTarget) {
|
||||
seekFallbackVisualTarget = null;
|
||||
} else if (Date.now() - seekFallbackVisualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) {
|
||||
// Keep UI at the requested position while backend catches up.
|
||||
displayTime = seekFallbackVisualTarget.seconds;
|
||||
} else {
|
||||
seekFallbackVisualTarget = null;
|
||||
}
|
||||
}
|
||||
const dur = duration > 0 ? duration : track.duration;
|
||||
if (dur <= 0) return;
|
||||
const progress = current_time / dur;
|
||||
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 });
|
||||
const progress = displayTime / dur;
|
||||
usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 });
|
||||
|
||||
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
|
||||
if (progress >= 0.5 && !store.scrobbled) {
|
||||
@@ -966,7 +1067,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
invoke('audio_stop').catch(console.error);
|
||||
}
|
||||
isAudioPaused = false;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
clearSeekFallbackRetry();
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
||||
set({
|
||||
isPlaying: false,
|
||||
progress: 0,
|
||||
@@ -986,7 +1088,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
clearRadioReconnectTimer();
|
||||
radioReconnectCount = 0;
|
||||
gaplessPreloadingId = null; bytePreloadingId = null;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
clearSeekFallbackRetry();
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
||||
// Stop Rust engine in case a regular track was playing.
|
||||
invoke('audio_stop').catch(() => {});
|
||||
// Resolve PLS/M3U playlist URLs to the actual stream URL before handing
|
||||
@@ -1028,9 +1131,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
const gen = ++playGeneration;
|
||||
isAudioPaused = false;
|
||||
gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
if (seekFallbackRetryTimer) { clearTimeout(seekFallbackRetryTimer); seekFallbackRetryTimer = null; }
|
||||
seekFallbackTrackId = null;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
||||
clearSeekFallbackRetry();
|
||||
seekFallbackRestartAt = 0;
|
||||
|
||||
// If a radio stream is active, stop it before the new track starts so
|
||||
@@ -1044,8 +1146,20 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
|
||||
const state = get();
|
||||
const prevTrack = state.currentTrack;
|
||||
seekFallbackTrackId = prevTrack?.id === track.id ? seekFallbackTrackId : null;
|
||||
if (seekFallbackVisualTarget?.trackId !== track.id) {
|
||||
seekFallbackVisualTarget = null;
|
||||
}
|
||||
const newQueue = queue ?? state.queue;
|
||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||
const pendingVisualTarget = seekFallbackVisualTarget?.trackId === track.id
|
||||
? seekFallbackVisualTarget.seconds
|
||||
: null;
|
||||
const initialTime = pendingVisualTarget !== null
|
||||
? Math.max(0, Math.min(pendingVisualTarget, track.duration || pendingVisualTarget))
|
||||
: 0;
|
||||
const initialProgress =
|
||||
track.duration && track.duration > 0 ? Math.max(0, Math.min(1, initialTime / track.duration)) : 0;
|
||||
|
||||
const authState = useAuthStore.getState();
|
||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||
@@ -1073,9 +1187,9 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
currentRadio: null,
|
||||
queue: newQueue,
|
||||
queueIndex: idx >= 0 ? idx : 0,
|
||||
progress: 0,
|
||||
progress: initialProgress,
|
||||
buffered: 0,
|
||||
currentTime: 0,
|
||||
currentTime: initialTime,
|
||||
scrobbled: false,
|
||||
lastfmLoved: false,
|
||||
isPlaying: true, // optimistic — reverted on error
|
||||
@@ -1141,7 +1255,7 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
}));
|
||||
});
|
||||
}
|
||||
syncQueueToServer(newQueue, track, 0);
|
||||
syncQueueToServer(newQueue, track, initialTime);
|
||||
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
|
||||
},
|
||||
|
||||
@@ -1425,35 +1539,45 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
if (seekDebounce) clearTimeout(seekDebounce);
|
||||
seekDebounce = setTimeout(() => {
|
||||
seekDebounce = null;
|
||||
seekTarget = time;
|
||||
invoke('audio_seek', { seconds: time }).catch((err: unknown) => {
|
||||
invoke('audio_seek', { seconds: time }).then(() => {
|
||||
// Arm stale-progress guard only after backend acknowledged seek.
|
||||
setSeekTarget(time);
|
||||
seekFallbackVisualTarget = null;
|
||||
clearSeekFallbackRetry();
|
||||
}).catch((err: unknown) => {
|
||||
// Release the progress-tick guard so the UI doesn't freeze
|
||||
// waiting for a target the engine will never reach.
|
||||
seekTarget = null;
|
||||
clearSeekTarget();
|
||||
const msg = String(err ?? '');
|
||||
if (!msg.includes('not seekable')) {
|
||||
if (!isRecoverableSeekError(msg)) {
|
||||
console.error(err);
|
||||
seekFallbackVisualTarget = null;
|
||||
clearSeekFallbackRetry();
|
||||
return;
|
||||
}
|
||||
// Streaming-start path can be non-seekable until the download finishes.
|
||||
// Fallback: at most one restart burst per track, then keep only the latest retry seek.
|
||||
// Streaming-start path can be temporarily non-seekable or busy.
|
||||
// Keep UI at target and retry seek for a short bounded window.
|
||||
const s = get();
|
||||
if (!s.currentTrack) return;
|
||||
const now = Date.now();
|
||||
const sameBurst =
|
||||
seekFallbackTrackId === s.currentTrack.id
|
||||
&& now - seekFallbackRestartAt < 600;
|
||||
if (!sameBurst) {
|
||||
seekFallbackVisualTarget = {
|
||||
trackId: s.currentTrack.id,
|
||||
seconds: time,
|
||||
setAtMs: Date.now(),
|
||||
};
|
||||
// Keep stale progress ticks from snapping UI back to start while
|
||||
// recoverable seek retries are still in flight.
|
||||
setSeekTarget(time);
|
||||
if (msg.includes('not seekable') && !sameBurst) {
|
||||
seekFallbackTrackId = s.currentTrack.id;
|
||||
seekFallbackRestartAt = now;
|
||||
// Keep manual semantics (no crossfade) for seek recovery restarts.
|
||||
s.playTrack(s.currentTrack, s.queue, true);
|
||||
}
|
||||
if (seekFallbackRetryTimer) clearTimeout(seekFallbackRetryTimer);
|
||||
seekFallbackRetryTimer = setTimeout(() => {
|
||||
seekFallbackRetryTimer = null;
|
||||
invoke('audio_seek', { seconds: time }).catch(() => {});
|
||||
}, 220);
|
||||
scheduleSeekFallbackRetry(s.currentTrack.id, time);
|
||||
});
|
||||
}, 100);
|
||||
},
|
||||
@@ -1533,7 +1657,8 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
clearQueue: () => {
|
||||
invoke('audio_stop').catch(console.error);
|
||||
isAudioPaused = false;
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||
clearSeekFallbackRetry();
|
||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget();
|
||||
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
syncQueueToServer([], null, 0);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user