refactor(player): E.23 — extract engine state + seek debounce cluster (#587)

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).
This commit is contained in:
Frank Stellmacher
2026-05-12 17:14:37 +02:00
committed by GitHub
parent 0eb084c5f8
commit 6355946610
5 changed files with 257 additions and 68 deletions
+54
View File
@@ -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);
});
});
+43
View File
@@ -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;
}
+66 -68
View File
@@ -131,6 +131,17 @@ import {
setSeekFallbackTrackId, setSeekFallbackTrackId,
setSeekFallbackVisualTarget, setSeekFallbackVisualTarget,
} from './seekFallbackState'; } from './seekFallbackState';
import {
armSeekDebounce,
clearSeekDebounce,
isSeekDebouncePending,
} from './seekDebounce';
import {
bumpPlayGeneration,
getIsAudioPaused,
getPlayGeneration,
setIsAudioPaused,
} from './engineState';
// Re-export so TauriEventBridge + persistence test keep their existing // Re-export so TauriEventBridge + persistence test keep their existing
// `from './playerStore'` imports. // `from './playerStore'` imports.
@@ -381,15 +392,6 @@ type NormalizationStatePayload = {
// ─── Module-level playback primitives ───────────────────────────────────────── // ─── 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. // Guard against concurrent infinite-queue fetches.
let infiniteQueueFetching = false; let infiniteQueueFetching = false;
// Guard against concurrent radio top-up fetches. // Guard against concurrent radio top-up fetches.
@@ -447,7 +449,7 @@ function queueUndoRestoreAudioEngine(opts: {
streamFormatSuffix: track.suffix ?? null, streamFormatSuffix: track.suffix ?? null,
}) })
.then(() => { .then(() => {
if (playGeneration !== generation) return; if (getPlayGeneration() !== generation) return;
if (keepPreloadHint) { if (keepPreloadHint) {
usePlayerStore.setState({ enginePreloadedTrackId: null }); usePlayerStore.setState({ enginePreloadedTrackId: null });
} }
@@ -455,13 +457,13 @@ function queueUndoRestoreAudioEngine(opts: {
const seekTo = Math.max(0, atSeconds); const seekTo = Math.max(0, atSeconds);
const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05); const canSeek = seekTo > 0.05 && (dur == null || seekTo < dur - 0.05);
const afterSeek = () => { const afterSeek = () => {
if (playGeneration !== generation) return; if (getPlayGeneration() !== generation) return;
if (!wantPlaying) { if (!wantPlaying) {
invoke('audio_pause').catch(console.error); invoke('audio_pause').catch(console.error);
isAudioPaused = true; setIsAudioPaused(true);
usePlayerStore.setState({ isPlaying: false }); usePlayerStore.setState({ isPlaying: false });
} else { } else {
isAudioPaused = false; setIsAudioPaused(false);
} }
}; };
if (canSeek) { if (canSeek) {
@@ -471,7 +473,7 @@ function queueUndoRestoreAudioEngine(opts: {
} }
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (playGeneration !== generation) return; if (getPlayGeneration() !== generation) return;
console.error('[psysonic] queue-undo audio_play failed:', err); console.error('[psysonic] queue-undo audio_play failed:', err);
usePlayerStore.setState({ isPlaying: false }); usePlayerStore.setState({ isPlaying: false });
}) })
@@ -481,8 +483,6 @@ function queueUndoRestoreAudioEngine(opts: {
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? ''); touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
} }
// Debounce timer for seek slider drags.
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries. // 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 // While a seek is pending, the store already holds the optimistic target
// position. Accepting stale progress from the Rust engine would briefly // position. Accepting stale progress from the Rust engine would briefly
// snap the waveform back to the old position before the seek completes. // 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 12 ticks with the old // After the debounce fires, Rust may still emit 12 ticks with the old
// position before the seek takes effect. Block until current_time is // position before the seek takes effect. Block until current_time is
// within 2 s of the requested target, then clear the guard. // 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. // Radio stream disconnected — just stop; don't advance queue.
if (usePlayerStore.getState().currentRadio) { if (usePlayerStore.getState().currentRadio) {
isAudioPaused = false; setIsAudioPaused(false);
usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 }); usePlayerStore.setState({ isPlaying: false, currentRadio: null, progress: 0, currentTime: 0 });
return; return;
} }
const { repeatMode, currentTrack, queue, queueIndex } = usePlayerStore.getState(); const { repeatMode, currentTrack, queue, queueIndex } = usePlayerStore.getState();
isAudioPaused = false; setIsAudioPaused(false);
usePlayerStore.setState({ usePlayerStore.setState({
isPlaying: false, isPlaying: false,
progress: 0, progress: 0,
@@ -776,7 +776,7 @@ function handleAudioEnded() {
function handleAudioTrackSwitched(duration: number) { function handleAudioTrackSwitched(duration: number) {
markGaplessSwitch(); markGaplessSwitch();
clearPreloadingIds(); // allow preloading for the track after this one clearPreloadingIds(); // allow preloading for the track after this one
isAudioPaused = false; setIsAudioPaused(false);
const store = usePlayerStore.getState(); const store = usePlayerStore.getState();
if (store.currentTrack?.id) { if (store.currentTrack?.id) {
@@ -847,15 +847,15 @@ function handleAudioTrackSwitched(duration: number) {
function handleAudioError(message: string) { function handleAudioError(message: string) {
console.error('[psysonic] Audio error from backend:', message); console.error('[psysonic] Audio error from backend:', message);
isAudioPaused = false; setIsAudioPaused(false);
const detail = message.length > 80 ? message.slice(0, 80) + '…' : message; const detail = message.length > 80 ? message.slice(0, 80) + '…' : message;
showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error'); showToast(`Couldn't play track — skipping. ${detail}`, 8000, 'error');
const gen = playGeneration; const gen = getPlayGeneration();
usePlayerStore.setState({ isPlaying: false }); usePlayerStore.setState({ isPlaying: false });
setTimeout(() => { setTimeout(() => {
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
usePlayerStore.getState().next(false); usePlayerStore.getState().next(false);
}, 1500); }, 1500);
} }
@@ -1399,12 +1399,12 @@ export const usePlayerStore = create<PlayerState>()(
clearPreloadingIds(); clearPreloadingIds();
let gen = playGeneration; let gen = getPlayGeneration();
const resyncEngine = Boolean(nextTrack) && !keepPlaybackFromPrior; const resyncEngine = Boolean(nextTrack) && !keepPlaybackFromPrior;
if (resyncEngine || !nextTrack) { if (resyncEngine || !nextTrack) {
gen = ++playGeneration; gen = bumpPlayGeneration();
if (resyncEngine) { if (resyncEngine) {
isAudioPaused = false; setIsAudioPaused(false);
} }
} }
@@ -1424,7 +1424,7 @@ export const usePlayerStore = create<PlayerState>()(
if (!nextTrack) { if (!nextTrack) {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
syncQueueToServer(nextQueue, null, 0); syncQueueToServer(nextQueue, null, 0);
if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) { if (typeof snap.queueListScrollTop === 'number' && Number.isFinite(snap.queueListScrollTop)) {
setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop)); setPendingQueueListScrollTop(Math.max(0, snap.queueListScrollTop));
@@ -1590,9 +1590,9 @@ export const usePlayerStore = create<PlayerState>()(
} else { } else {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
} }
isAudioPaused = false; setIsAudioPaused(false);
clearSeekFallbackRetry(); clearSeekFallbackRetry();
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); clearSeekDebounce(); clearSeekTarget();
set({ set({
isPlaying: false, isPlaying: false,
progress: 0, progress: 0,
@@ -1615,14 +1615,14 @@ export const usePlayerStore = create<PlayerState>()(
// ── playRadio ──────────────────────────────────────────────────────────── // ── playRadio ────────────────────────────────────────────────────────────
playRadio: async (station) => { playRadio: async (station) => {
const { volume } = get(); const { volume } = get();
++playGeneration; bumpPlayGeneration();
clearAllPlaybackScheduleTimers(); clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null }); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
isAudioPaused = false; setIsAudioPaused(false);
clearRadioReconnectTimer(); clearRadioReconnectTimer();
clearPreloadingIds(); clearPreloadingIds();
clearSeekFallbackRetry(); clearSeekFallbackRetry();
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); clearSeekDebounce(); clearSeekTarget();
// Stop Rust engine in case a regular track was playing. // Stop Rust engine in case a regular track was playing.
invoke('audio_stop').catch(() => {}); invoke('audio_stop').catch(() => {});
// Resolve PLS/M3U playlist URLs to the actual stream URL before handing // Resolve PLS/M3U playlist URLs to the actual stream URL before handing
@@ -1721,10 +1721,10 @@ export const usePlayerStore = create<PlayerState>()(
clearAllPlaybackScheduleTimers(); clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null }); set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
const gen = ++playGeneration; const gen = bumpPlayGeneration();
isAudioPaused = false; setIsAudioPaused(false);
clearPreloadingIds(); // new track — allow fresh preload for next clearPreloadingIds(); // new track — allow fresh preload for next
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); clearSeekDebounce(); clearSeekTarget();
clearSeekFallbackRetry(); clearSeekFallbackRetry();
setSeekFallbackRestartAt(0); setSeekFallbackRestartAt(0);
@@ -1860,7 +1860,7 @@ export const usePlayerStore = create<PlayerState>()(
streamFormatSuffix: track.suffix ?? null, streamFormatSuffix: track.suffix ?? null,
}) })
.then(() => { .then(() => {
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
if (keepPreloadHint) { if (keepPreloadHint) {
usePlayerStore.setState({ enginePreloadedTrackId: null }); usePlayerStore.setState({ enginePreloadedTrackId: null });
} }
@@ -1871,7 +1871,7 @@ export const usePlayerStore = create<PlayerState>()(
if (canSeekAfterPlay) { if (canSeekAfterPlay) {
void invoke('audio_seek', { seconds: seekTo }) void invoke('audio_seek', { seconds: seekTo })
.then(() => { .then(() => {
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
setSeekTarget(seekTo); setSeekTarget(seekTo);
if (getSeekFallbackVisualTarget()?.trackId === track.id) { if (getSeekFallbackVisualTarget()?.trackId === track.id) {
setSeekFallbackVisualTarget(null); setSeekFallbackVisualTarget(null);
@@ -1885,12 +1885,12 @@ export const usePlayerStore = create<PlayerState>()(
} }
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false); setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play failed:', err); console.error('[psysonic] audio_play failed:', err);
set({ isPlaying: false }); set({ isPlaying: false });
setTimeout(() => { setTimeout(() => {
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
get().next(false); get().next(false);
}, 500); }, 500);
}); });
@@ -1920,11 +1920,11 @@ export const usePlayerStore = create<PlayerState>()(
authState.hotCacheDownloadDir || null, authState.hotCacheDownloadDir || null,
) )
.then(() => { .then(() => {
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
runPlayTrackBody(); runPlayTrackBody();
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false); setDeferHotCachePrefetch(false);
console.error('[psysonic] same-track hot promote / play body failed:', err); console.error('[psysonic] same-track hot promote / play body failed:', err);
set({ isPlaying: false }); set({ isPlaying: false });
@@ -1979,7 +1979,7 @@ export const usePlayerStore = create<PlayerState>()(
pauseRadio(); pauseRadio();
} else { } else {
invoke('audio_pause').catch(console.error); invoke('audio_pause').catch(console.error);
isAudioPaused = true; setIsAudioPaused(true);
// Flush position so a quick close after pause still leaves the // Flush position so a quick close after pause still leaves the
// server with the right resume point for other devices. // server with the right resume point for other devices.
const s = get(); const s = get();
@@ -1991,7 +1991,7 @@ export const usePlayerStore = create<PlayerState>()(
}, },
resetAudioPause: () => { resetAudioPause: () => {
isAudioPaused = false; setIsAudioPaused(false);
}, },
resume: () => { resume: () => {
@@ -2020,9 +2020,9 @@ export const usePlayerStore = create<PlayerState>()(
// Same track: seek + un-pause via the Rust engine directly. // Same track: seek + un-pause via the Rust engine directly.
// Bypasses this resume() branch re-entry via the early return below. // Bypasses this resume() branch re-entry via the early return below.
get().seek(fraction); get().seek(fraction);
if (isAudioPaused) { if (getIsAudioPaused()) {
invoke('audio_resume').catch(console.error); invoke('audio_resume').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
set({ isPlaying: true }); set({ isPlaying: true });
} else { } else {
set({ isPlaying: true }); set({ isPlaying: true });
@@ -2051,17 +2051,17 @@ export const usePlayerStore = create<PlayerState>()(
const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null; const coldPrev = queueIndex > 0 ? queue[queueIndex - 1] : null;
const coldNext = queueIndex + 1 < queue.length ? 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. // Rust engine has audio loaded but paused — just resume it.
invoke('audio_resume').catch(console.error); invoke('audio_resume').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
set({ isPlaying: true }); set({ isPlaying: true });
touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? ''); touchHotCacheOnPlayback(currentTrack.id, useAuthStore.getState().activeServerId ?? '');
} else { } else {
// Engine has no loaded paused stream (app relaunch, or track ended and user // Engine has no loaded paused stream (app relaunch, or track ended and user
// hits play — `isAudioPaused` is false after `audio:ended`). Flush any // hits play — `isAudioPaused` is false after `audio:ended`). Flush any
// `stream_completed_cache` from the prior play to hot disk before resolving URL. // `stream_completed_cache` from the prior play to hot disk before resolving URL.
const gen = ++playGeneration; const gen = bumpPlayGeneration();
const vol = get().volume; const vol = get().volume;
set({ isPlaying: true }); set({ isPlaying: true });
@@ -2075,11 +2075,11 @@ export const usePlayerStore = create<PlayerState>()(
authHot.hotCacheDownloadDir || null, authHot.hotCacheDownloadDir || null,
); );
} }
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
// Fetch fresh track data from server to get replay gain metadata // Fetch fresh track data from server to get replay gain metadata
getSong(currentTrack.id).then(freshSong => { getSong(currentTrack.id).then(freshSong => {
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack; const trackToPlay = freshSong ? songToTrack(freshSong) : currentTrack;
// Update store with fresh track data if available // Update store with fresh track data if available
if (freshSong) set({ currentTrack: trackToPlay }); if (freshSong) set({ currentTrack: trackToPlay });
@@ -2109,18 +2109,18 @@ export const usePlayerStore = create<PlayerState>()(
analysisTrackId: trackToPlay.id, analysisTrackId: trackToPlay.id,
streamFormatSuffix: trackToPlay.suffix ?? null, streamFormatSuffix: trackToPlay.suffix ?? null,
}).then(() => { }).then(() => {
if (playGeneration === gen && currentTime > 1) { if (getPlayGeneration() === gen && currentTime > 1) {
invoke('audio_seek', { seconds: currentTime }).catch(console.error); invoke('audio_seek', { seconds: currentTime }).catch(console.error);
} }
}).catch((err: unknown) => { }).catch((err: unknown) => {
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false); setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err); console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false }); set({ isPlaying: false });
}); });
syncQueueToServer(queue, trackToPlay, currentTime); syncQueueToServer(queue, trackToPlay, currentTime);
}).catch(() => { }).catch(() => {
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
// Fallback to currentTrack if fetch fails // Fallback to currentTrack if fetch fails
const authStateCold = useAuthStore.getState(); const authStateCold = useAuthStore.getState();
const replayGainDbCold = resolveReplayGainDb( const replayGainDbCold = resolveReplayGainDb(
@@ -2148,7 +2148,7 @@ export const usePlayerStore = create<PlayerState>()(
analysisTrackId: currentTrack.id, analysisTrackId: currentTrack.id,
streamFormatSuffix: currentTrack.suffix ?? null, streamFormatSuffix: currentTrack.suffix ?? null,
}).catch((err: unknown) => { }).catch((err: unknown) => {
if (playGeneration !== gen) return; if (getPlayGeneration() !== gen) return;
setDeferHotCachePrefetch(false); setDeferHotCachePrefetch(false);
console.error('[psysonic] audio_play (cold resume) failed:', err); console.error('[psysonic] audio_play (cold resume) failed:', err);
set({ isPlaying: false }); set({ isPlaying: false });
@@ -2291,7 +2291,7 @@ export const usePlayerStore = create<PlayerState>()(
// so a fetch scheduled mid-join doesn't slip through. // so a fetch scheduled mid-join doesn't slip through.
if (isInOrbitSession()) { if (isInOrbitSession()) {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return; return;
} }
@@ -2308,7 +2308,7 @@ export const usePlayerStore = create<PlayerState>()(
// fetch was in flight — bail without touching the queue. // fetch was in flight — bail without touching the queue.
if (isInOrbitSession()) { if (isInOrbitSession()) {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return; return;
} }
@@ -2330,14 +2330,14 @@ export const usePlayerStore = create<PlayerState>()(
get().playTrack(fresh[0], newQueue, false, false, currentQueue.length); get().playTrack(fresh[0], newQueue, false, false, currentQueue.length);
} else { } else {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
} }
}) })
.catch(() => { .catch(() => {
radioFetching = false; radioFetching = false;
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
}); });
return; return;
@@ -2354,13 +2354,13 @@ export const usePlayerStore = create<PlayerState>()(
// fetch was in flight — bail without invoking playTrack. // fetch was in flight — bail without invoking playTrack.
if (isInOrbitSession()) { if (isInOrbitSession()) {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return; return;
} }
if (newTracks.length === 0) { if (newTracks.length === 0) {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
return; return;
} }
@@ -2370,12 +2370,12 @@ export const usePlayerStore = create<PlayerState>()(
}).catch(() => { }).catch(() => {
infiniteQueueFetching = false; infiniteQueueFetching = false;
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
}); });
} else { } else {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
} }
} }
@@ -2410,9 +2410,7 @@ export const usePlayerStore = create<PlayerState>()(
if (!dur || !isFinite(dur)) return; if (!dur || !isFinite(dur)) return;
const time = Math.max(0, Math.min(progress * dur, dur - 0.25)); const time = Math.max(0, Math.min(progress * dur, dur - 0.25));
set({ progress: time / dur, currentTime: time }); set({ progress: time / dur, currentTime: time });
if (seekDebounce) clearTimeout(seekDebounce); armSeekDebounce(100, () => {
seekDebounce = setTimeout(() => {
seekDebounce = null;
const s0 = get(); const s0 = get();
if (!s0.currentTrack) return; if (!s0.currentTrack) return;
const authSeek = useAuthStore.getState(); const authSeek = useAuthStore.getState();
@@ -2467,7 +2465,7 @@ export const usePlayerStore = create<PlayerState>()(
} }
scheduleSeekFallbackRetry(s.currentTrack.id, time); scheduleSeekFallbackRetry(s.currentTrack.id, time);
}); });
}, 100); });
}, },
// ── volume ─────────────────────────────────────────────────────────────── // ── volume ───────────────────────────────────────────────────────────────
@@ -2611,9 +2609,9 @@ export const usePlayerStore = create<PlayerState>()(
clearQueue: () => { clearQueue: () => {
invoke('audio_stop').catch(console.error); invoke('audio_stop').catch(console.error);
isAudioPaused = false; setIsAudioPaused(false);
clearSeekFallbackRetry(); clearSeekFallbackRetry();
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); clearSeekDebounce(); clearSeekTarget();
radioSessionSeenIds = new Set(); radioSessionSeenIds = new Set();
currentRadioArtistId = null; currentRadioArtistId = null;
set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 }); set({ queue: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
+56
View File
@@ -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();
});
});
+38
View File
@@ -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<typeof setTimeout> | 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();
}