mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
committed by
GitHub
parent
0eb084c5f8
commit
6355946610
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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 1–2 ticks with the old
|
// 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
|
// 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 });
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user