diff --git a/src/store/gaplessPreloadState.test.ts b/src/store/gaplessPreloadState.test.ts new file mode 100644 index 00000000..63485283 --- /dev/null +++ b/src/store/gaplessPreloadState.test.ts @@ -0,0 +1,91 @@ +/** + * Three mutables that coordinate the gapless preloader. Most of the surface + * is straight get/set — the interesting bit is `clearPreloadingIds` (atomic + * clear of both) and `markGaplessSwitch` (timestamp side effect). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _resetGaplessPreloadStateForTest, + clearPreloadingIds, + getBytePreloadingId, + getGaplessPreloadingId, + getLastGaplessSwitchTime, + markGaplessSwitch, + setBytePreloadingId, + setGaplessPreloadingId, +} from './gaplessPreloadState'; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-12T12:00:00Z')); +}); + +afterEach(() => { + _resetGaplessPreloadStateForTest(); + vi.useRealTimers(); +}); + +describe('initial state', () => { + it('is null / 0 for unread accessors', () => { + expect(getGaplessPreloadingId()).toBeNull(); + expect(getBytePreloadingId()).toBeNull(); + expect(getLastGaplessSwitchTime()).toBe(0); + }); +}); + +describe('preloading-id accessors', () => { + it('round-trips through the gapless guard', () => { + setGaplessPreloadingId('t1'); + expect(getGaplessPreloadingId()).toBe('t1'); + }); + + it('round-trips through the byte guard', () => { + setBytePreloadingId('t2'); + expect(getBytePreloadingId()).toBe('t2'); + }); + + it('keeps the two guards independent', () => { + setGaplessPreloadingId('a'); + setBytePreloadingId('b'); + expect(getGaplessPreloadingId()).toBe('a'); + expect(getBytePreloadingId()).toBe('b'); + }); + + it('accepts null to clear a guard', () => { + setGaplessPreloadingId('a'); + setGaplessPreloadingId(null); + expect(getGaplessPreloadingId()).toBeNull(); + }); +}); + +describe('clearPreloadingIds', () => { + it('clears both guards atomically', () => { + setGaplessPreloadingId('a'); + setBytePreloadingId('b'); + clearPreloadingIds(); + expect(getGaplessPreloadingId()).toBeNull(); + expect(getBytePreloadingId()).toBeNull(); + }); + + it('does not touch the gapless-switch timestamp', () => { + markGaplessSwitch(); + const before = getLastGaplessSwitchTime(); + clearPreloadingIds(); + expect(getLastGaplessSwitchTime()).toBe(before); + }); +}); + +describe('markGaplessSwitch', () => { + it('stamps Date.now()', () => { + markGaplessSwitch(); + expect(getLastGaplessSwitchTime()).toBe(Date.now()); + }); + + it('overwrites on a later call', () => { + markGaplessSwitch(); + const first = getLastGaplessSwitchTime(); + vi.advanceTimersByTime(700); + markGaplessSwitch(); + expect(getLastGaplessSwitchTime()).toBeGreaterThan(first); + }); +}); diff --git a/src/store/gaplessPreloadState.ts b/src/store/gaplessPreloadState.ts new file mode 100644 index 00000000..d6c07217 --- /dev/null +++ b/src/store/gaplessPreloadState.ts @@ -0,0 +1,57 @@ +/** + * Coordinates the gapless-chain preloader so the runtime doesn't pre-fetch + * the same track twice or fire a chain-switch while a previous one is + * still settling. + * + * - `gaplessPreloadingId` — track id last handed to `audio_chain_preload` + * - `bytePreloadingId` — track id last handed to `audio_preload` + * - `lastGaplessSwitchTime` — timestamp of the last gapless track-switch + * event from Rust. The 500–600 ms guards in `handleAudioTrackSwitched` + * and the progress handler use this to suppress stale IPC arriving + * right after the switch. + * + * `clearPreloadingIds` collapses the repeated `= null; = null;` pattern + * that the store actions used to inline in five places. + */ + +let gaplessPreloadingId: string | null = null; +let bytePreloadingId: string | null = null; +let lastGaplessSwitchTime = 0; + +export function getGaplessPreloadingId(): string | null { + return gaplessPreloadingId; +} + +export function setGaplessPreloadingId(id: string | null): void { + gaplessPreloadingId = id; +} + +export function getBytePreloadingId(): string | null { + return bytePreloadingId; +} + +export function setBytePreloadingId(id: string | null): void { + bytePreloadingId = id; +} + +/** Atomic: clear both preloading guards. Called on track switch + on errors. */ +export function clearPreloadingIds(): void { + gaplessPreloadingId = null; + bytePreloadingId = null; +} + +export function getLastGaplessSwitchTime(): number { + return lastGaplessSwitchTime; +} + +/** Record a gapless switch event. Subsequent guards compare against this. */ +export function markGaplessSwitch(): void { + lastGaplessSwitchTime = Date.now(); +} + +/** Test-only: reset all three mutables. */ +export function _resetGaplessPreloadStateForTest(): void { + gaplessPreloadingId = null; + bytePreloadingId = null; + lastGaplessSwitchTime = 0; +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index df52b395..f99edf6b 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -95,6 +95,15 @@ import { getLastQueueHeartbeatAt, syncQueueToServer, } from './queueSync'; +import { + clearPreloadingIds, + getBytePreloadingId, + getGaplessPreloadingId, + getLastGaplessSwitchTime, + markGaplessSwitch, + setBytePreloadingId, + setGaplessPreloadingId, +} from './gaplessPreloadState'; // Re-export so TauriEventBridge + persistence test keep their existing // `from './playerStore'` imports. @@ -624,10 +633,6 @@ radioAudio.addEventListener('suspend', () => { clearRadioReconnectTimer(); }); -// Timestamp of the last gapless auto-advance (from audio:track_switched). -// Used to suppress ghost-commands from stale IPC arriving after the switch. -let lastGaplessSwitchTime = 0; - /** Coalesce concurrent `analysis_get_loudness_for_track` for one id+mode pair. The * analysis:waveform-updated listener fires refreshWaveform + refreshLoudness in * parallel for every full-track analysis completion; without coalescing, gapless @@ -821,11 +826,6 @@ async function promoteCompletedStreamToHotCache(track: Track, serverId: string, } } -// Track ID that has already been sent to audio_chain_preload (gapless chain). -let gaplessPreloadingId: string | null = null; -// Track ID that has already been sent to audio_preload (byte pre-download). -let bytePreloadingId: string | null = null; - // ─── Audio event handlers (called from initAudioListeners) ─────────────────── function handleAudioPlaying(_duration: number) { @@ -994,8 +994,8 @@ function handleAudioProgress(current_time: number, duration: number) { const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId); // Byte pre-download — runs early so bytes are cached by chain time. - if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) { - bytePreloadingId = nextTrack.id; + if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== getBytePreloadingId()) { + setBytePreloadingId(nextTrack.id); // Loudness cache only — do not call refreshWaveformForTrack(next): it writes global // waveformBins and would replace the current track's seekbar while still playing it. void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false }); @@ -1017,8 +1017,8 @@ function handleAudioProgress(current_time: number, duration: number) { } // Gapless chain — decode + chain into Sink 30s before track boundary. - if (shouldChainGapless && nextTrack.id !== gaplessPreloadingId) { - gaplessPreloadingId = nextTrack.id; + if (shouldChainGapless && nextTrack.id !== getGaplessPreloadingId()) { + setGaplessPreloadingId(nextTrack.id); // Ensure loudness gain is already cached for the chained request payload. void refreshLoudnessForTrack(nextTrack.id, { syncPlayingEngine: false }); const authState = useAuthStore.getState(); @@ -1053,7 +1053,7 @@ function handleAudioProgress(current_time: number, duration: number) { function handleAudioEnded() { // If a gapless switch happened recently, this ended event is stale — the // progress task fired it for the OLD source before seeing the chained one. - if (Date.now() - lastGaplessSwitchTime < 600) { + if (Date.now() - getLastGaplessSwitchTime() < 600) { return; } @@ -1101,8 +1101,8 @@ function handleAudioEnded() { * touching the audio stream (no playTrack() call!). */ function handleAudioTrackSwitched(duration: number) { - lastGaplessSwitchTime = Date.now(); - gaplessPreloadingId = null; bytePreloadingId = null; // allow preloading for the track after this one + markGaplessSwitch(); + clearPreloadingIds(); // allow preloading for the track after this one isAudioPaused = false; const store = usePlayerStore.getState(); @@ -1727,8 +1727,7 @@ export const usePlayerStore = create()( scheduledResumeStartMs: null, }); - gaplessPreloadingId = null; - bytePreloadingId = null; + clearPreloadingIds(); let gen = playGeneration; const resyncEngine = Boolean(nextTrack) && !keepPlaybackFromPrior; @@ -1955,7 +1954,7 @@ export const usePlayerStore = create()( isAudioPaused = false; clearRadioReconnectTimer(); radioReconnectCount = 0; - gaplessPreloadingId = null; bytePreloadingId = null; + clearPreloadingIds(); clearSeekFallbackRetry(); if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); // Stop Rust engine in case a regular track was playing. @@ -2052,7 +2051,7 @@ export const usePlayerStore = create()( // Ghost-command guard: if a gapless switch happened within 500 ms, // this playTrack call is likely a stale IPC echo — suppress it. - if (Date.now() - lastGaplessSwitchTime < 500) { + if (Date.now() - getLastGaplessSwitchTime() < 500) { return; } @@ -2061,7 +2060,7 @@ export const usePlayerStore = create()( const gen = ++playGeneration; isAudioPaused = false; - gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next + clearPreloadingIds(); // new track — allow fresh preload for next if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } clearSeekTarget(); clearSeekFallbackRetry(); seekFallbackRestartAt = 0;