From 635594661089c3a07160b25febd1693ee86e1840 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 17:14:37 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.23=20=E2=80=94=20extract?= =?UTF-8?q?=20engine=20state=20+=20seek=20debounce=20cluster=20(#587)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two thematic cuts in one PR: - `src/store/engineState.ts` — `isAudioPaused` (warm-vs-cold-resume decision flag) + `playGeneration` (monotonically bumped guard counter used by long-running async callbacks to detect concurrent playTrack and bail). Get/set accessors + `bumpPlayGeneration()` that returns the new value (mirrors the original `++playGeneration` pattern). - `src/store/seekDebounce.ts` — seek-slider debounce timer behind `armSeekDebounce(delayMs, onFire)` / `clearSeekDebounce()` / `isSeekDebouncePending()`. Collapses the recurring inline `if (seekDebounce) { clearTimeout(...); seekDebounce = null; }` triple into single calls. Sed-driven bulk rewrite for the >60 direct-access sites preserved semantics verbatim (one JSDoc reference to `isAudioPaused` kept as comment text). 14 tests across the two modules. playerStore 2869 → 2867 LOC net (small delta — the imports are bigger than the savings, but the mutables are no longer reachable from outside their owning modules). --- src/store/engineState.test.ts | 54 +++++++++++++ src/store/engineState.ts | 43 +++++++++++ src/store/playerStore.ts | 134 ++++++++++++++++----------------- src/store/seekDebounce.test.ts | 56 ++++++++++++++ src/store/seekDebounce.ts | 38 ++++++++++ 5 files changed, 257 insertions(+), 68 deletions(-) create mode 100644 src/store/engineState.test.ts create mode 100644 src/store/engineState.ts create mode 100644 src/store/seekDebounce.test.ts create mode 100644 src/store/seekDebounce.ts diff --git a/src/store/engineState.test.ts b/src/store/engineState.test.ts new file mode 100644 index 00000000..f73f6c82 --- /dev/null +++ b/src/store/engineState.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + _resetEngineStateForTest, + bumpPlayGeneration, + getIsAudioPaused, + getPlayGeneration, + setIsAudioPaused, +} from './engineState'; + +afterEach(() => { + _resetEngineStateForTest(); +}); + +describe('isAudioPaused', () => { + it('starts false', () => { + expect(getIsAudioPaused()).toBe(false); + }); + + it('round-trips through get/set', () => { + setIsAudioPaused(true); + expect(getIsAudioPaused()).toBe(true); + setIsAudioPaused(false); + expect(getIsAudioPaused()).toBe(false); + }); +}); + +describe('playGeneration', () => { + it('starts at 0', () => { + expect(getPlayGeneration()).toBe(0); + }); + + it('bumpPlayGeneration increments + returns the new value', () => { + expect(bumpPlayGeneration()).toBe(1); + expect(bumpPlayGeneration()).toBe(2); + expect(getPlayGeneration()).toBe(2); + }); + + it('captures a snapshot that a later bump invalidates', () => { + const snap = bumpPlayGeneration(); + bumpPlayGeneration(); + expect(getPlayGeneration()).not.toBe(snap); + }); +}); + +describe('_resetEngineStateForTest', () => { + it('resets both fields', () => { + setIsAudioPaused(true); + bumpPlayGeneration(); + bumpPlayGeneration(); + _resetEngineStateForTest(); + expect(getIsAudioPaused()).toBe(false); + expect(getPlayGeneration()).toBe(0); + }); +}); diff --git a/src/store/engineState.ts b/src/store/engineState.ts new file mode 100644 index 00000000..c9dd3a8e --- /dev/null +++ b/src/store/engineState.ts @@ -0,0 +1,43 @@ +/** + * Two pieces of state that coordinate the Rust audio engine from JS: + * + * - **isAudioPaused** — true when the engine has a loaded-but-paused + * track. `resume()` reads this to decide between `audio_resume` (warm + * path, just unpause the existing Sink) and a cold restart via + * `audio_play + audio_seek`. Set true on every pause, false on every + * successful play / seek. + * + * - **playGeneration** — monotonically increasing counter bumped at the + * start of every play attempt. Long-running `.then`/`.finally` + * callbacks capture their generation at start and compare against the + * current value before applying state changes; a mismatch means the + * user moved on and the callback should bail out without touching the + * store. Prevents stale callbacks from snapping playback back to a + * track the user already left. + */ + +let isAudioPaused = false; +let playGeneration = 0; + +export function getIsAudioPaused(): boolean { + return isAudioPaused; +} + +export function setIsAudioPaused(value: boolean): void { + isAudioPaused = value; +} + +export function getPlayGeneration(): number { + return playGeneration; +} + +/** Bump + return the new generation in one call (mirrors `++playGeneration`). */ +export function bumpPlayGeneration(): number { + return ++playGeneration; +} + +/** Test-only: reset both fields so each spec starts from a clean slate. */ +export function _resetEngineStateForTest(): void { + isAudioPaused = false; + playGeneration = 0; +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index c716287d..d72392c7 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -131,6 +131,17 @@ import { setSeekFallbackTrackId, setSeekFallbackVisualTarget, } from './seekFallbackState'; +import { + armSeekDebounce, + clearSeekDebounce, + isSeekDebouncePending, +} from './seekDebounce'; +import { + bumpPlayGeneration, + getIsAudioPaused, + getPlayGeneration, + setIsAudioPaused, +} from './engineState'; // Re-export so TauriEventBridge + persistence test keep their existing // `from './playerStore'` imports. @@ -381,15 +392,6 @@ type NormalizationStatePayload = { // ─── Module-level playback primitives ───────────────────────────────────────── -// isAudioPaused — true when the Rust audio engine has a loaded-but-paused track. -// Used by resume() to decide between audio_resume (warm) vs audio_play (cold start). -let isAudioPaused = false; - -// JS-side generation counter. Incremented on every playTrack() call. -// The invoke().catch() error handler captures its own gen and bails if -// playGeneration has moved on, preventing stale errors from skipping wrong tracks. -let playGeneration = 0; - // Guard against concurrent infinite-queue fetches. let infiniteQueueFetching = false; // Guard against concurrent radio top-up fetches. @@ -447,7 +449,7 @@ function queueUndoRestoreAudioEngine(opts: { streamFormatSuffix: track.suffix ?? null, }) .then(() => { - if (playGeneration !== generation) return; + if (getPlayGeneration() !== generation) return; if (keepPreloadHint) { usePlayerStore.setState({ enginePreloadedTrackId: null }); } @@ -455,13 +457,13 @@ function queueUndoRestoreAudioEngine(opts: { const seekTo = Math.max(0, atSeconds); const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05); const afterSeek = () => { - if (playGeneration !== generation) return; + if (getPlayGeneration() !== generation) return; if (!wantPlaying) { invoke('audio_pause').catch(console.error); - isAudioPaused = true; + setIsAudioPaused(true); usePlayerStore.setState({ isPlaying: false }); } else { - isAudioPaused = false; + setIsAudioPaused(false); } }; if (canSeek) { @@ -471,7 +473,7 @@ function queueUndoRestoreAudioEngine(opts: { } }) .catch((err: unknown) => { - if (playGeneration !== generation) return; + if (getPlayGeneration() !== generation) return; console.error('[psysonic] queue-undo audio_play failed:', err); usePlayerStore.setState({ isPlaying: false }); }) @@ -481,8 +483,6 @@ function queueUndoRestoreAudioEngine(opts: { touchHotCacheOnPlayback(track.id, authState.activeServerId ?? ''); } -// Debounce timer for seek slider drags. -let seekDebounce: ReturnType | null = null; // Streaming fallback seek guard: coalesce repeated "not seekable" recoveries. @@ -513,7 +513,7 @@ function handleAudioProgress(current_time: number, duration: number) { // While a seek is pending, the store already holds the optimistic target // position. Accepting stale progress from the Rust engine would briefly // snap the waveform back to the old position before the seek completes. - if (seekDebounce) return; + if (isSeekDebouncePending()) return; // After the debounce fires, Rust may still emit 1–2 ticks with the old // position before the seek takes effect. Block until current_time is // within 2 s of the requested target, then clear the guard. @@ -732,13 +732,13 @@ function handleAudioEnded() { // Radio stream disconnected — just stop; don't advance queue. if (usePlayerStore.getState().currentRadio) { - isAudioPaused = false; + setIsAudioPaused(false); usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 }); return; } const { repeatMode, currentTrack, queue, queueIndex } = usePlayerStore.getState(); - isAudioPaused = false; + setIsAudioPaused(false); usePlayerStore.setState({ isPlaying: false, progress: 0, @@ -776,7 +776,7 @@ function handleAudioEnded() { function handleAudioTrackSwitched(duration: number) { markGaplessSwitch(); clearPreloadingIds(); // allow preloading for the track after this one - isAudioPaused = false; + setIsAudioPaused(false); const store = usePlayerStore.getState(); if (store.currentTrack?.id) { @@ -847,15 +847,15 @@ function handleAudioTrackSwitched(duration: number) { function handleAudioError(message: string) { console.error('[psysonic] Audio error from backend:', message); - isAudioPaused = false; + setIsAudioPaused(false); const detail = message.length > 80 ? message.slice(0, 80) + '…' : message; showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error'); - const gen = playGeneration; + const gen = getPlayGeneration(); usePlayerStore.setState({ isPlaying: false }); setTimeout(() => { - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; usePlayerStore.getState().next(false); }, 1500); } @@ -1399,12 +1399,12 @@ export const usePlayerStore = create()( clearPreloadingIds(); - let gen = playGeneration; + let gen = getPlayGeneration(); const resyncEngine = Boolean(nextTrack) && !keepPlaybackFromPrior; if (resyncEngine || !nextTrack) { - gen = ++playGeneration; + gen = bumpPlayGeneration(); if (resyncEngine) { - isAudioPaused = false; + setIsAudioPaused(false); } } @@ -1424,7 +1424,7 @@ export const usePlayerStore = create()( if (!nextTrack) { invoke('audio_stop').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); syncQueueToServer(nextQueue, null, 0); if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) { setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop)); @@ -1590,9 +1590,9 @@ export const usePlayerStore = create()( } else { invoke('audio_stop').catch(console.error); } - isAudioPaused = false; + setIsAudioPaused(false); clearSeekFallbackRetry(); - if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); + clearSeekDebounce(); clearSeekTarget(); set({ isPlaying: false, progress: 0, @@ -1615,14 +1615,14 @@ export const usePlayerStore = create()( // ── playRadio ──────────────────────────────────────────────────────────── playRadio: async (station) => { const { volume } = get(); - ++playGeneration; + bumpPlayGeneration(); clearAllPlaybackScheduleTimers(); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null }); - isAudioPaused = false; + setIsAudioPaused(false); clearRadioReconnectTimer(); clearPreloadingIds(); clearSeekFallbackRetry(); - if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); + clearSeekDebounce(); 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 @@ -1721,10 +1721,10 @@ export const usePlayerStore = create()( clearAllPlaybackScheduleTimers(); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null }); - const gen = ++playGeneration; - isAudioPaused = false; + const gen = bumpPlayGeneration(); + setIsAudioPaused(false); clearPreloadingIds(); // new track — allow fresh preload for next - if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); + clearSeekDebounce(); clearSeekTarget(); clearSeekFallbackRetry(); setSeekFallbackRestartAt(0); @@ -1860,7 +1860,7 @@ export const usePlayerStore = create()( streamFormatSuffix: track.suffix ?? null, }) .then(() => { - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; if (keepPreloadHint) { usePlayerStore.setState({ enginePreloadedTrackId: null }); } @@ -1871,7 +1871,7 @@ export const usePlayerStore = create()( if (canSeekAfterPlay) { void invoke('audio_seek', { seconds: seekTo }) .then(() => { - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; setSeekTarget(seekTo); if (getSeekFallbackVisualTarget()?.trackId === track.id) { setSeekFallbackVisualTarget(null); @@ -1885,12 +1885,12 @@ export const usePlayerStore = create()( } }) .catch((err: unknown) => { - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; setDeferHotCachePrefetch(false); console.error('[psysonic] audio_play failed:', err); set({ isPlaying: false }); setTimeout(() => { - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; get().next(false); }, 500); }); @@ -1920,11 +1920,11 @@ export const usePlayerStore = create()( authState.hotCacheDownloadDir || null, ) .then(() => { - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; runPlayTrackBody(); }) .catch((err: unknown) => { - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; setDeferHotCachePrefetch(false); console.error('[psysonic] same-track hot promote / play body failed:', err); set({ isPlaying: false }); @@ -1979,7 +1979,7 @@ export const usePlayerStore = create()( pauseRadio(); } else { invoke('audio_pause').catch(console.error); - isAudioPaused = true; + setIsAudioPaused(true); // Flush position so a quick close after pause still leaves the // server with the right resume point for other devices. const s = get(); @@ -1991,7 +1991,7 @@ export const usePlayerStore = create()( }, resetAudioPause: () => { - isAudioPaused = false; + setIsAudioPaused(false); }, resume: () => { @@ -2020,9 +2020,9 @@ export const usePlayerStore = create()( // Same track: seek + un-pause via the Rust engine directly. // Bypasses this resume() branch re-entry via the early return below. get().seek(fraction); - if (isAudioPaused) { + if (getIsAudioPaused()) { invoke('audio_resume').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); set({ isPlaying: true }); } else { set({ isPlaying: true }); @@ -2051,17 +2051,17 @@ export const usePlayerStore = create()( const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null; const coldNext = queueIndex + 1 < queue.length ? queue[queueIndex + 1] : null; - if (isAudioPaused) { + if (getIsAudioPaused()) { // Rust engine has audio loaded but paused — just resume it. invoke('audio_resume').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); set({ isPlaying: true }); touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? ''); } else { // Engine has no loaded paused stream (app relaunch, or track ended and user // hits play — `isAudioPaused` is false after `audio:ended`). Flush any // `stream_completed_cache` from the prior play to hot disk before resolving URL. - const gen = ++playGeneration; + const gen = bumpPlayGeneration(); const vol = get().volume; set({ isPlaying: true }); @@ -2075,11 +2075,11 @@ export const usePlayerStore = create()( authHot.hotCacheDownloadDir || null, ); } - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; // Fetch fresh track data from server to get replay gain metadata getSong(currentTrack.id).then(freshSong => { - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack; // Update store with fresh track data if available if (freshSong) set({ currentTrack: trackToPlay }); @@ -2109,18 +2109,18 @@ export const usePlayerStore = create()( analysisTrackId: trackToPlay.id, streamFormatSuffix: trackToPlay.suffix ?? null, }).then(() => { - if (playGeneration === gen && currentTime > 1) { + if (getPlayGeneration() === gen && currentTime > 1) { invoke('audio_seek', { seconds: currentTime }).catch(console.error); } }).catch((err: unknown) => { - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; setDeferHotCachePrefetch(false); console.error('[psysonic] audio_play (cold resume) failed:', err); set({ isPlaying: false }); }); syncQueueToServer(queue, trackToPlay, currentTime); }).catch(() => { - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; // Fallback to currentTrack if fetch fails const authStateCold = useAuthStore.getState(); const replayGainDbCold = resolveReplayGainDb( @@ -2148,7 +2148,7 @@ export const usePlayerStore = create()( analysisTrackId: currentTrack.id, streamFormatSuffix: currentTrack.suffix ?? null, }).catch((err: unknown) => { - if (playGeneration !== gen) return; + if (getPlayGeneration() !== gen) return; setDeferHotCachePrefetch(false); console.error('[psysonic] audio_play (cold resume) failed:', err); set({ isPlaying: false }); @@ -2291,7 +2291,7 @@ export const usePlayerStore = create()( // so a fetch scheduled mid-join doesn't slip through. if (isInOrbitSession()) { invoke('audio_stop').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); return; } @@ -2308,7 +2308,7 @@ export const usePlayerStore = create()( // fetch was in flight — bail without touching the queue. if (isInOrbitSession()) { invoke('audio_stop').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); return; } @@ -2330,14 +2330,14 @@ export const usePlayerStore = create()( get().playTrack(fresh[0], newQueue, false, false, currentQueue.length); } else { invoke('audio_stop').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); } }) .catch(() => { radioFetching = false; invoke('audio_stop').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); }); return; @@ -2354,13 +2354,13 @@ export const usePlayerStore = create()( // fetch was in flight — bail without invoking playTrack. if (isInOrbitSession()) { invoke('audio_stop').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); return; } if (newTracks.length === 0) { invoke('audio_stop').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); return; } @@ -2370,12 +2370,12 @@ export const usePlayerStore = create()( }).catch(() => { infiniteQueueFetching = false; invoke('audio_stop').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); }); } else { invoke('audio_stop').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); } } @@ -2410,9 +2410,7 @@ export const usePlayerStore = create()( if (!dur || !isFinite(dur)) return; const time = Math.max(0, Math.min(progress * dur, dur - 0.25)); set({ progress: time / dur, currentTime: time }); - if (seekDebounce) clearTimeout(seekDebounce); - seekDebounce = setTimeout(() => { - seekDebounce = null; + armSeekDebounce(100, () => { const s0 = get(); if (!s0.currentTrack) return; const authSeek = useAuthStore.getState(); @@ -2467,7 +2465,7 @@ export const usePlayerStore = create()( } scheduleSeekFallbackRetry(s.currentTrack.id, time); }); - }, 100); + }); }, // ── volume ─────────────────────────────────────────────────────────────── @@ -2611,9 +2609,9 @@ export const usePlayerStore = create()( clearQueue: () => { invoke('audio_stop').catch(console.error); - isAudioPaused = false; + setIsAudioPaused(false); clearSeekFallbackRetry(); - if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); + clearSeekDebounce(); clearSeekTarget(); radioSessionSeenIds = new Set(); currentRadioArtistId = null; set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); diff --git a/src/store/seekDebounce.test.ts b/src/store/seekDebounce.test.ts new file mode 100644 index 00000000..718ca542 --- /dev/null +++ b/src/store/seekDebounce.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _resetSeekDebounceForTest, + armSeekDebounce, + clearSeekDebounce, + isSeekDebouncePending, +} from './seekDebounce'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + _resetSeekDebounceForTest(); + vi.useRealTimers(); +}); + +describe('seekDebounce', () => { + it('starts not pending', () => { + expect(isSeekDebouncePending()).toBe(false); + }); + + it('armSeekDebounce flips pending true and fires the callback after the delay', () => { + const cb = vi.fn(); + armSeekDebounce(100, cb); + expect(isSeekDebouncePending()).toBe(true); + vi.advanceTimersByTime(99); + expect(cb).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(cb).toHaveBeenCalledTimes(1); + expect(isSeekDebouncePending()).toBe(false); + }); + + it('arming again before fire replaces the callback', () => { + const first = vi.fn(); + const second = vi.fn(); + armSeekDebounce(100, first); + armSeekDebounce(100, second); + vi.advanceTimersByTime(100); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('clearSeekDebounce cancels a pending fire', () => { + const cb = vi.fn(); + armSeekDebounce(100, cb); + clearSeekDebounce(); + expect(isSeekDebouncePending()).toBe(false); + vi.advanceTimersByTime(1000); + expect(cb).not.toHaveBeenCalled(); + }); + + it('clearSeekDebounce is a no-op when nothing is pending', () => { + expect(() => clearSeekDebounce()).not.toThrow(); + }); +}); diff --git a/src/store/seekDebounce.ts b/src/store/seekDebounce.ts new file mode 100644 index 00000000..58bcc280 --- /dev/null +++ b/src/store/seekDebounce.ts @@ -0,0 +1,38 @@ +/** + * Debounce timer for seek-slider drags. The waveform / seekbar emit a + * seek event for every pixel of cursor movement; without debouncing each + * one would hit `audio_seek` and the engine would thrash. The 100 ms + * window in `playerStore.seek` collapses rapid drags into one actual + * seek call. + * + * `isSeekDebouncePending` is the guard the progress handler reads to + * suppress stale ticks during a drag: the slider's local set already + * paints the target position, so a Rust progress event for the old + * playhead would snap the UI back. + */ + +let seekDebounceTimer: ReturnType | null = null; + +export function isSeekDebouncePending(): boolean { + return seekDebounceTimer !== null; +} + +export function armSeekDebounce(delayMs: number, onFire: () => void): void { + if (seekDebounceTimer) clearTimeout(seekDebounceTimer); + seekDebounceTimer = setTimeout(() => { + seekDebounceTimer = null; + onFire(); + }, delayMs); +} + +export function clearSeekDebounce(): void { + if (seekDebounceTimer) { + clearTimeout(seekDebounceTimer); + seekDebounceTimer = null; + } +} + +/** Test-only: force-clear without firing the callback. */ +export function _resetSeekDebounceForTest(): void { + clearSeekDebounce(); +}