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
+62 -38
View File
@@ -1,5 +1,5 @@
use std::io::{Cursor, Read, Seek, SeekFrom};
use std::sync::{Arc, Mutex, OnceLock};
use std::sync::{Arc, Mutex, OnceLock, TryLockError};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
#[cfg(unix)]
@@ -709,39 +709,14 @@ impl Read for RangedHttpSource {
let target_end = self.pos + max_read as u64;
let deadline = Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
#[cfg(debug_assertions)]
let block_started = Instant::now();
#[cfg(debug_assertions)]
let mut blocked = false;
loop {
if self.gen_arc.load(Ordering::SeqCst) != self.gen {
return Ok(0);
}
let dl = self.downloaded_to.load(Ordering::SeqCst) as u64;
if dl >= target_end {
#[cfg(debug_assertions)]
if blocked {
eprintln!(
"[stream] read unblocked after {:.2}s — pos={} target_end={} dl={}",
block_started.elapsed().as_secs_f64(),
self.pos,
target_end,
dl
);
}
break;
}
#[cfg(debug_assertions)]
if !blocked {
blocked = true;
eprintln!(
"[stream] read blocking — pos={} need={} dl_to={} (waiting for {} more bytes)",
self.pos,
target_end,
dl,
target_end - dl
);
}
// Download finished but our cursor is past downloaded_to (e.g. seek
// beyond a partial download that aborted). Return what we have.
if self.done.load(Ordering::SeqCst) {
@@ -2032,7 +2007,7 @@ pub struct AudioEngine {
pub(crate) current_is_seekable: Arc<AtomicBool>,
pub crossfade_enabled: Arc<AtomicBool>,
pub crossfade_secs: Arc<AtomicU32>,
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
pub fading_out_sink: Arc<Mutex<Option<Arc<Sink>>>>,
/// When true, audio_play chains sources to the existing Sink instead of
/// creating a new one, achieving sample-accurate gapless transitions.
pub gapless_enabled: Arc<AtomicBool>,
@@ -2055,7 +2030,7 @@ pub struct AudioEngine {
}
pub struct AudioCurrent {
pub sink: Option<Sink>,
pub sink: Option<Arc<Sink>>,
pub duration_secs: f64,
pub seek_offset: f64,
pub play_started: Option<Instant>,
@@ -2885,7 +2860,7 @@ pub async fn audio_play(
}
}
let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?;
let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?);
sink.set_volume(effective_volume);
// ── Sink pre-fill for hi-res tracks ──────────────────────────────────────
@@ -3414,6 +3389,8 @@ pub fn audio_stop(state: State<'_, AudioEngine>) {
#[tauri::command]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
const AUDIO_SEEK_TIMEOUT_MS: u64 = 700;
const AUDIO_SEEK_LOCK_TIMEOUT_MS: u64 = 40;
// Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance.
{
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
@@ -3439,26 +3416,73 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str
#[cfg(debug_assertions)]
eprintln!("[seek] target={:.2}s", seconds);
let lock_current_with_timeout = |timeout_ms: u64| {
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
loop {
match state.current.try_lock() {
Ok(guard) => break Ok(guard),
Err(TryLockError::WouldBlock) => {
if Instant::now() >= deadline {
break Err("audio seek busy".to_string());
}
std::thread::sleep(Duration::from_millis(2));
}
Err(TryLockError::Poisoned(_)) => {
break Err("audio state lock poisoned".to_string());
}
}
}
};
// Seeking back invalidates any pending gapless chain.
let cur_pos = {
let cur = state.current.lock().unwrap();
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
cur.position()
};
if seconds < cur_pos - 1.0 {
*state.chained_info.lock().unwrap() = None;
}
let mut cur = state.current.lock().unwrap();
let seek_seconds = seconds.max(0.0);
let seek_duration = Duration::from_secs_f64(seek_seconds);
let seek_generation = state.generation.load(Ordering::SeqCst);
let sink = {
let cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
match cur.sink.as_ref() {
Some(sink) => Arc::clone(sink),
None => return Ok(()),
}
};
let (tx, rx) = std::sync::mpsc::channel::<Result<(), String>>();
std::thread::spawn(move || {
let result = sink.try_seek(seek_duration).map_err(|e| e.to_string());
let _ = tx.send(result);
});
match rx.recv_timeout(Duration::from_millis(AUDIO_SEEK_TIMEOUT_MS)) {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
return Err("audio seek timeout".into());
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
return Err("audio seek worker disconnected".into());
}
}
// If playback switched while seek was in flight, skip timestamp updates.
if state.generation.load(Ordering::SeqCst) != seek_generation {
return Ok(());
}
let mut cur = lock_current_with_timeout(AUDIO_SEEK_LOCK_TIMEOUT_MS)?;
if cur.sink.is_none() { return Ok(()); }
cur.sink.as_ref().unwrap()
.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
.map_err(|e| e.to_string())?;
if cur.paused_at.is_some() {
cur.paused_at = Some(seconds);
cur.paused_at = Some(seek_seconds);
} else {
cur.seek_offset = seconds;
cur.seek_offset = seek_seconds;
cur.play_started = Some(Instant::now());
}
Ok(())
@@ -3704,7 +3728,7 @@ pub async fn audio_play_radio(
if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); }
let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?;
let sink = Arc::new(Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?);
sink.set_volume((volume.clamp(0.0, 1.0) * MASTER_HEADROOM).clamp(0.0, 1.0));
sink.append(counting);
+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);
};
+150 -25
View File
@@ -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);
},