From 0eb084c5f85750d83170a07badfc9af11b57176d Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 17:01:22 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.22=20=E2=80=94=20extract?= =?UTF-8?q?=20seek-fallback=20retry=20+=20visual=20target=20state=20(#586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming-fallback seek recovery loop + its visual coverup move into `src/store/seekFallbackState.ts`: - 6 module mutables (retry timer + start + target; fallback trackId + restartAt; visual target) - 3 const gates (180 ms retry interval, 6 s retry budget, 1.6 s visual guard) - `scheduleSeekFallbackRetry(trackId, seconds)` — bounded retry loop that hits `audio_seek` every 180 ms up to 6 s, re-scheduling on recoverable errors and clearing the visual target on success or hard failure - `clearSeekFallbackRetry()` — cancel timer + reset state - get/set accessors for the three caller-touched fields (`SeekFallbackVisualTarget`, trackId, restartAt) playerStore's ~30 direct-access sites become accessor calls. Inside the progress handler the visual target is captured into a local once per call so type narrowing + repeated reads stay clean. 17 focused tests pin the constants, get/set round-trips, the retry loop's happy path (setSeekTarget + clear visual), recoverable retry re-schedule, non-recoverable abort, track-change abort, retry-budget exhaustion, and the three coalesce cases (different track id / seconds delta > 0.25 s / seconds delta ≤ 0.25 s). playerStore 2909 → 2869 LOC. --- src/store/playerStore.ts | 148 +++++++------------- src/store/seekFallbackState.test.ts | 201 ++++++++++++++++++++++++++++ src/store/seekFallbackState.ts | 128 ++++++++++++++++++ 3 files changed, 380 insertions(+), 97 deletions(-) create mode 100644 src/store/seekFallbackState.test.ts create mode 100644 src/store/seekFallbackState.ts diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 8e864599..c716287d 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -120,6 +120,17 @@ import { markStoreProgressCommit, resetProgressEmitThrottles, } from './playbackThrottles'; +import { + SEEK_FALLBACK_VISUAL_GUARD_MS, + clearSeekFallbackRetry, + getSeekFallbackRestartAt, + getSeekFallbackTrackId, + getSeekFallbackVisualTarget, + scheduleSeekFallbackRetry, + setSeekFallbackRestartAt, + setSeekFallbackTrackId, + setSeekFallbackVisualTarget, +} from './seekFallbackState'; // Re-export so TauriEventBridge + persistence test keep their existing // `from './playerStore'` imports. @@ -473,70 +484,8 @@ function queueUndoRestoreAudioEngine(opts: { // Debounce timer for seek slider drags. let seekDebounce: ReturnType | null = null; // 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 clearSeekFallbackRetry() { - if (seekFallbackRetryTimer) { - clearTimeout(seekFallbackRetryTimer); - seekFallbackRetryTimer = null; - } - seekFallbackRetryStartedAt = 0; - seekFallbackRetryTarget = null; -} - -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); -} - function prefetchLoudnessForEnqueuedTracks( mergedQueue: Track[], queueIndex: number, @@ -585,23 +534,24 @@ function handleAudioProgress(current_time: number, duration: number) { // Some backends can emit stale progress ticks shortly after pause/stop. // Ignoring them avoids reactivating UI redraw loops while transport is idle. const transportActive = store.isPlaying || store.currentRadio != null; - if (!transportActive && !seekFallbackVisualTarget) return; - if (seekFallbackVisualTarget && seekFallbackVisualTarget.trackId !== track.id) { - seekFallbackVisualTarget = null; + let visualTarget = getSeekFallbackVisualTarget(); + if (!transportActive && !visualTarget) return; + if (visualTarget && visualTarget.trackId !== track.id) { + setSeekFallbackVisualTarget(null); + visualTarget = null; } let displayTime = current_time; - if ( - seekFallbackVisualTarget - && seekFallbackVisualTarget.trackId === track.id - ) { - const nearTarget = Math.abs(current_time - seekFallbackVisualTarget.seconds) <= 2.0; + if (visualTarget && visualTarget.trackId === track.id) { + const nearTarget = Math.abs(current_time - visualTarget.seconds) <= 2.0; if (nearTarget) { - seekFallbackVisualTarget = null; - } else if (Date.now() - seekFallbackVisualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) { + setSeekFallbackVisualTarget(null); + visualTarget = null; + } else if (Date.now() - visualTarget.setAtMs <= SEEK_FALLBACK_VISUAL_GUARD_MS) { // Keep UI at the requested position while backend catches up. - displayTime = seekFallbackVisualTarget.seconds; + displayTime = visualTarget.seconds; } else { - seekFallbackVisualTarget = null; + setSeekFallbackVisualTarget(null); + visualTarget = null; } } const dur = duration > 0 ? duration : track.duration; @@ -614,7 +564,7 @@ function handleAudioProgress(current_time: number, duration: number) { if ( nowLive - getLastLiveProgressEmitAt() >= LIVE_PROGRESS_EMIT_MIN_MS || liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC || - seekFallbackVisualTarget != null + visualTarget != null ) { emitPlaybackProgress({ currentTime: displayTime, @@ -649,7 +599,7 @@ function handleAudioProgress(current_time: number, duration: number) { const nowCommit = Date.now(); const commitDelta = Math.abs(store.currentTime - displayTime); const shouldCommitStore = - seekFallbackVisualTarget != null || + visualTarget != null || nowCommit - getLastStoreProgressCommitAt() >= STORE_PROGRESS_COMMIT_MIN_MS || commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC; if (shouldCommitStore) { @@ -1776,7 +1726,7 @@ export const usePlayerStore = create()( clearPreloadingIds(); // new track — allow fresh preload for next if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); clearSeekFallbackRetry(); - seekFallbackRestartAt = 0; + setSeekFallbackRestartAt(0); // If a radio stream is active, stop it before the new track starts so // the PlayerBar clears radio mode immediately and the stream is released. @@ -1786,9 +1736,12 @@ 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; + if (prevTrack?.id !== track.id) { + setSeekFallbackTrackId(null); + } + const visualOnEntry = getSeekFallbackVisualTarget(); + if (visualOnEntry?.trackId !== track.id) { + setSeekFallbackVisualTarget(null); } const newQueue = queue ?? state.queue; // Prefer an explicit target index from the caller (next/previous/queue-row @@ -1806,8 +1759,9 @@ export const usePlayerStore = create()( if (manual) { pushQueueUndoFromGetter(get); } - const pendingVisualTarget = seekFallbackVisualTarget?.trackId === track.id - ? seekFallbackVisualTarget.seconds + const visualForInitial = getSeekFallbackVisualTarget(); + const pendingVisualTarget = visualForInitial?.trackId === track.id + ? visualForInitial.seconds : null; const initialTime = pendingVisualTarget !== null ? Math.max(0, Math.min(pendingVisualTarget, track.duration || pendingVisualTarget)) @@ -1919,13 +1873,13 @@ export const usePlayerStore = create()( .then(() => { if (playGeneration !== gen) return; setSeekTarget(seekTo); - if (seekFallbackVisualTarget?.trackId === track.id) { - seekFallbackVisualTarget = null; + if (getSeekFallbackVisualTarget()?.trackId === track.id) { + setSeekFallbackVisualTarget(null); } }) .catch(() => { - if (seekFallbackVisualTarget?.trackId === track.id) { - seekFallbackVisualTarget = null; + if (getSeekFallbackVisualTarget()?.trackId === track.id) { + setSeekFallbackVisualTarget(null); } }); } @@ -2435,7 +2389,7 @@ export const usePlayerStore = create()( const authState = useAuthStore.getState(); const sid = authState.activeServerId ?? ''; if (currentTrack && shouldRebindPlaybackToHotCache(currentTrack.id, sid)) { - seekFallbackVisualTarget = { trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() }; + setSeekFallbackVisualTarget({ trackId: currentTrack.id, seconds: 0, setAtMs: Date.now() }); get().playTrack(currentTrack, queue, true); return; } @@ -2464,11 +2418,11 @@ export const usePlayerStore = create()( const authSeek = useAuthStore.getState(); const sidSeek = authSeek.activeServerId ?? ''; if (shouldRebindPlaybackToHotCache(s0.currentTrack.id, sidSeek)) { - seekFallbackVisualTarget = { + setSeekFallbackVisualTarget({ trackId: s0.currentTrack.id, seconds: time, setAtMs: Date.now(), - }; + }); clearSeekFallbackRetry(); s0.playTrack(s0.currentTrack, s0.queue, true); return; @@ -2476,7 +2430,7 @@ export const usePlayerStore = create()( invoke('audio_seek', { seconds: time }).then(() => { // Arm stale-progress guard only after backend acknowledged seek. setSeekTarget(time); - seekFallbackVisualTarget = null; + setSeekFallbackVisualTarget(null); clearSeekFallbackRetry(); }).catch((err: unknown) => { // Release the progress-tick guard so the UI doesn't freeze @@ -2485,7 +2439,7 @@ export const usePlayerStore = create()( const msg = String(err ?? ''); if (!isRecoverableSeekError(msg)) { console.error(err); - seekFallbackVisualTarget = null; + setSeekFallbackVisualTarget(null); clearSeekFallbackRetry(); return; } @@ -2495,19 +2449,19 @@ export const usePlayerStore = create()( if (!s.currentTrack) return; const now = Date.now(); const sameBurst = - seekFallbackTrackId === s.currentTrack.id - && now - seekFallbackRestartAt < 600; - seekFallbackVisualTarget = { + getSeekFallbackTrackId() === s.currentTrack.id + && now - getSeekFallbackRestartAt() < 600; + setSeekFallbackVisualTarget({ 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; + setSeekFallbackTrackId(s.currentTrack.id); + setSeekFallbackRestartAt(now); // Keep manual semantics (no crossfade) for seek recovery restarts. s.playTrack(s.currentTrack, s.queue, true); } diff --git a/src/store/seekFallbackState.test.ts b/src/store/seekFallbackState.test.ts new file mode 100644 index 00000000..4f3aa48d --- /dev/null +++ b/src/store/seekFallbackState.test.ts @@ -0,0 +1,201 @@ +/** + * Seek-fallback retry loop + visual target accessors. The retry timer + * fires every 180 ms (SEEK_FALLBACK_RETRY_INTERVAL_MS) up to 6 s + * (SEEK_FALLBACK_RETRY_MAX_MS). Success calls setSeekTarget + clears the + * visual target; recoverable errors re-schedule; non-recoverable errors + * abort + clear visual target. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => { + const playerState = { currentTrack: null as { id: string } | null }; + return { + invokeMock: vi.fn(async (_cmd: string, _args?: Record) => undefined), + setSeekTargetMock: vi.fn(), + isRecoverableSeekErrorMock: vi.fn((msg: string) => msg.includes('not seekable')), + playerStateGet: () => playerState, + playerState, + }; +}); + +vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock })); +vi.mock('../utils/seekErrors', () => ({ isRecoverableSeekError: hoisted.isRecoverableSeekErrorMock })); +vi.mock('./seekTargetState', () => ({ setSeekTarget: hoisted.setSeekTargetMock })); +vi.mock('./playerStore', () => ({ + usePlayerStore: { getState: hoisted.playerStateGet }, +})); + +import { + SEEK_FALLBACK_RETRY_INTERVAL_MS, + SEEK_FALLBACK_RETRY_MAX_MS, + SEEK_FALLBACK_VISUAL_GUARD_MS, + _resetSeekFallbackStateForTest, + clearSeekFallbackRetry, + getSeekFallbackRestartAt, + getSeekFallbackTrackId, + getSeekFallbackVisualTarget, + scheduleSeekFallbackRetry, + setSeekFallbackRestartAt, + setSeekFallbackTrackId, + setSeekFallbackVisualTarget, +} from './seekFallbackState'; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-12T12:00:00Z')); + hoisted.invokeMock.mockReset(); + hoisted.invokeMock.mockResolvedValue(undefined); + hoisted.setSeekTargetMock.mockClear(); + hoisted.isRecoverableSeekErrorMock.mockReset(); + hoisted.isRecoverableSeekErrorMock.mockImplementation((msg: string) => msg.includes('not seekable')); + hoisted.playerState.currentTrack = { id: 't1' }; +}); + +afterEach(() => { + _resetSeekFallbackStateForTest(); + vi.useRealTimers(); +}); + +describe('constants', () => { + it('match the values the runtime expects', () => { + expect(SEEK_FALLBACK_VISUAL_GUARD_MS).toBe(1600); + expect(SEEK_FALLBACK_RETRY_INTERVAL_MS).toBe(180); + expect(SEEK_FALLBACK_RETRY_MAX_MS).toBe(6000); + }); +}); + +describe('visual target accessors', () => { + it('start at null', () => { + expect(getSeekFallbackVisualTarget()).toBeNull(); + }); + + it('round-trip through set/get', () => { + const target = { trackId: 't1', seconds: 42, setAtMs: 1000 }; + setSeekFallbackVisualTarget(target); + expect(getSeekFallbackVisualTarget()).toEqual(target); + }); + + it('accept null to clear', () => { + setSeekFallbackVisualTarget({ trackId: 't1', seconds: 1, setAtMs: 0 }); + setSeekFallbackVisualTarget(null); + expect(getSeekFallbackVisualTarget()).toBeNull(); + }); +}); + +describe('trackId + restartAt accessors', () => { + it('start at null / 0', () => { + expect(getSeekFallbackTrackId()).toBeNull(); + expect(getSeekFallbackRestartAt()).toBe(0); + }); + + it('round-trip', () => { + setSeekFallbackTrackId('t1'); + setSeekFallbackRestartAt(12345); + expect(getSeekFallbackTrackId()).toBe('t1'); + expect(getSeekFallbackRestartAt()).toBe(12345); + }); +}); + +describe('clearSeekFallbackRetry', () => { + it('is a no-op when nothing is scheduled', () => { + expect(() => clearSeekFallbackRetry()).not.toThrow(); + }); + + it('cancels a pending retry', () => { + scheduleSeekFallbackRetry('t1', 10); + clearSeekFallbackRetry(); + vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_MAX_MS); + // No invoke fired because the timer was cancelled. + expect(hoisted.invokeMock).not.toHaveBeenCalled(); + }); +}); + +describe('scheduleSeekFallbackRetry — happy path', () => { + it('fires `audio_seek` after the retry interval', async () => { + scheduleSeekFallbackRetry('t1', 30); + vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_INTERVAL_MS); + expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 }); + }); + + it('calls setSeekTarget + clears visual target on a successful invoke', async () => { + setSeekFallbackVisualTarget({ trackId: 't1', seconds: 30, setAtMs: Date.now() }); + scheduleSeekFallbackRetry('t1', 30); + hoisted.invokeMock.mockResolvedValueOnce(undefined); + await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS); + await Promise.resolve(); + expect(hoisted.setSeekTargetMock).toHaveBeenCalledWith(30); + expect(getSeekFallbackVisualTarget()).toBeNull(); + }); +}); + +describe('scheduleSeekFallbackRetry — error branches', () => { + it('re-schedules on a recoverable error', async () => { + hoisted.invokeMock + .mockRejectedValueOnce(new Error('not seekable')) + .mockResolvedValueOnce(undefined); + scheduleSeekFallbackRetry('t1', 30); + await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS); + await Promise.resolve(); + // Second attempt scheduled — fire after another interval. + await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS); + await Promise.resolve(); + expect(hoisted.invokeMock).toHaveBeenCalledTimes(2); + expect(hoisted.setSeekTargetMock).toHaveBeenCalledWith(30); + }); + + it('clears visual target + aborts on non-recoverable error', async () => { + setSeekFallbackVisualTarget({ trackId: 't1', seconds: 30, setAtMs: Date.now() }); + hoisted.invokeMock.mockRejectedValueOnce(new Error('codec broken')); + hoisted.isRecoverableSeekErrorMock.mockReturnValueOnce(false); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + scheduleSeekFallbackRetry('t1', 30); + await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS); + await Promise.resolve(); + expect(getSeekFallbackVisualTarget()).toBeNull(); + consoleSpy.mockRestore(); + }); + + it('aborts when the timer fires after the track has changed', async () => { + scheduleSeekFallbackRetry('t1', 30); + hoisted.playerState.currentTrack = { id: 't2' }; + await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS); + expect(hoisted.invokeMock).not.toHaveBeenCalled(); + }); + + it('aborts when the retry budget is exhausted', async () => { + setSeekFallbackVisualTarget({ trackId: 't1', seconds: 30, setAtMs: Date.now() }); + // Advance system time past the budget BEFORE the scheduled callback runs. + scheduleSeekFallbackRetry('t1', 30); + vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_INTERVAL_MS / 2); + vi.setSystemTime(new Date(Date.now() + SEEK_FALLBACK_RETRY_MAX_MS + 1)); + await vi.advanceTimersByTimeAsync(SEEK_FALLBACK_RETRY_INTERVAL_MS); + expect(hoisted.invokeMock).not.toHaveBeenCalled(); + expect(getSeekFallbackVisualTarget()).toBeNull(); + }); +}); + +describe('scheduleSeekFallbackRetry — coalescing', () => { + it('replaces the target when track id changes', () => { + scheduleSeekFallbackRetry('t1', 30); + scheduleSeekFallbackRetry('t2', 45); + hoisted.playerState.currentTrack = { id: 't2' }; + vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_INTERVAL_MS); + expect(hoisted.invokeMock).toHaveBeenCalledTimes(1); + expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 45 }); + }); + + it('replaces the target when seconds differ by more than 0.25 s', () => { + scheduleSeekFallbackRetry('t1', 30); + scheduleSeekFallbackRetry('t1', 31); + vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_INTERVAL_MS); + expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 31 }); + }); + + it('reuses the target when seconds are close (≤0.25 s)', () => { + scheduleSeekFallbackRetry('t1', 30); + scheduleSeekFallbackRetry('t1', 30.1); + vi.advanceTimersByTime(SEEK_FALLBACK_RETRY_INTERVAL_MS); + // Original target (30) used because seconds are within 0.25. + expect(hoisted.invokeMock).toHaveBeenCalledWith('audio_seek', { seconds: 30 }); + }); +}); diff --git a/src/store/seekFallbackState.ts b/src/store/seekFallbackState.ts new file mode 100644 index 00000000..4bba0e6f --- /dev/null +++ b/src/store/seekFallbackState.ts @@ -0,0 +1,128 @@ +import { invoke } from '@tauri-apps/api/core'; +import { isRecoverableSeekError } from '../utils/seekErrors'; +import { usePlayerStore } from './playerStore'; +import { setSeekTarget } from './seekTargetState'; + +/** + * Streaming-fallback seek recovery + visual coverup. + * + * The Rust seek pipeline can reject a seek as "not seekable" while the + * stream is still settling (sink not bound yet, codec hasn't reported + * seekability). Instead of surfacing the failure straight away, this + * module schedules bounded retries every 180 ms up to a 6 s ceiling. + * + * In parallel, `seekFallbackVisualTarget` holds the seekbar at the + * requested position so the user doesn't see it snap back to the + * pre-seek time while the retry loop is in flight. The progress handler + * reads this and prefers the target value until the engine actually + * reaches it (within ~2 s) or the 1.6 s visual-guard window elapses. + * + * `seekFallbackTrackId` + `seekFallbackRestartAt` track the most recent + * fallback-restart so a quick subsequent seek on the same track can + * decide whether to debounce a full restart vs reuse the loop. + */ + +export const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600; +export const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180; +export const SEEK_FALLBACK_RETRY_MAX_MS = 6000; + +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; + +export function clearSeekFallbackRetry(): void { + if (seekFallbackRetryTimer) { + clearTimeout(seekFallbackRetryTimer); + seekFallbackRetryTimer = null; + } + seekFallbackRetryStartedAt = 0; + seekFallbackRetryTarget = null; +} + +export function scheduleSeekFallbackRetry(trackId: string, seconds: number): void { + 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); +} + +export type SeekFallbackVisualTarget = { + trackId: string; + seconds: number; + setAtMs: number; +}; + +export function getSeekFallbackVisualTarget(): SeekFallbackVisualTarget | null { + return seekFallbackVisualTarget; +} + +export function setSeekFallbackVisualTarget(target: SeekFallbackVisualTarget | null): void { + seekFallbackVisualTarget = target; +} + +export function getSeekFallbackTrackId(): string | null { + return seekFallbackTrackId; +} + +export function setSeekFallbackTrackId(id: string | null): void { + seekFallbackTrackId = id; +} + +export function getSeekFallbackRestartAt(): number { + return seekFallbackRestartAt; +} + +export function setSeekFallbackRestartAt(t: number): void { + seekFallbackRestartAt = t; +} + +/** Test-only: reset every mutable to its initial value. */ +export function _resetSeekFallbackStateForTest(): void { + if (seekFallbackRetryTimer) clearTimeout(seekFallbackRetryTimer); + seekFallbackRetryTimer = null; + seekFallbackRetryStartedAt = 0; + seekFallbackRetryTarget = null; + seekFallbackTrackId = null; + seekFallbackRestartAt = 0; + seekFallbackVisualTarget = null; +}