diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 3f894b2a..313f88c1 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -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, pub crossfade_enabled: Arc, pub crossfade_secs: Arc, - pub fading_out_sink: Arc>>, + pub fading_out_sink: Arc>>>, /// 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, @@ -2055,7 +2030,7 @@ pub struct AudioEngine { } pub struct AudioCurrent { - pub sink: Option, + pub sink: Option>, pub duration_secs: f64, pub seek_offset: f64, pub play_started: Option, @@ -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::>(); + 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); diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 4437330e..96bee923 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -428,6 +428,28 @@ const FsSeekbar = memo(function FsSeekbar({ duration }: { duration: number }) { const playedRef = useRef(null); const bufRef = useRef(null); const inputRef = useRef(null); + const isDraggingRef = useRef(false); + const pendingSeekRef = useRef(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) => seek(parseFloat(e.target.value)), - [seek] + (e: React.ChangeEvent) => { + 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" /> diff --git a/src/components/MobilePlayerView.tsx b/src/components/MobilePlayerView.tsx index 70da4eab..ade6f7b7 100644 --- a/src/components/MobilePlayerView.tsx +++ b/src/components/MobilePlayerView.tsx @@ -204,14 +204,21 @@ export default function MobilePlayerView() { // Scrubber touch/mouse drag const scrubberRef = useRef(null); const isDragging = useRef(false); + const pendingSeekRef = useRef(null); + const [previewProgress, setPreviewProgress] = useState(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 (
@@ -328,12 +352,12 @@ export default function MobilePlayerView() { onTouchStart={e => onScrubStart(e.touches[0].clientX)} >
-
-
+
+
- {formatTime(currentTime)} - -{formatTime(Math.max(0, duration - currentTime))} + {formatTime(effectiveTime)} + -{formatTime(Math.max(0, duration - effectiveTime))}
diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 26b067e7..082f1efd 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -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(null); const heightsRef = useRef(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(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); }; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 708abe16..e01903af 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -220,10 +220,88 @@ let seekDebounce: ReturnType | 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 | 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()( 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()( 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()( 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()( 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()( 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()( })); }); } - syncQueueToServer(newQueue, track, 0); + syncQueueToServer(newQueue, track, initialTime); touchHotCacheOnPlayback(track.id, authState.activeServerId ?? ''); }, @@ -1425,35 +1539,45 @@ export const usePlayerStore = create()( 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()( 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); },