mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
refactor(player): E.18 — extract three transport-coordination modules (#581)
Cluster of three small thematically-related cuts in one PR (per the new
'cluster, don't single-shot' convention):
- `src/store/seekTargetState.ts` — the seek-target guard (`seekTarget`,
`seekTargetSetAt`, `SEEK_TARGET_GUARD_TIMEOUT_MS`, set/clear/get
accessors) that suppresses stale Rust progress ticks until the
engine catches up to the requested position
- `src/store/togglePlayLock.ts` — the 300 ms double-click cooldown
behind a `tryAcquireTogglePlayLock()` helper that auto-releases on
a timer (collapses the three-line inline pattern in `togglePlay`)
- `src/store/loudnessReseed.ts` — the full `reseedLoudnessForTrackId`
pipeline (gen-bump → cache + backfill wipe → state reset → server
row delete → forced seed enqueue), pulled out of playerStore as a
single async helper
All three were file-private; no caller-side changes outside
playerStore's own imports. The progress handler's seek-guard branch is
now ~3 lines shorter and reads through accessors. `togglePlay` collapses
to one guard check.
24 tests across the three modules pin the API contracts.
playerStore 3189 → 3139 LOC.
This commit is contained in:
committed by
GitHub
parent
9029ab8ec5
commit
4c64844349
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* `reseedLoudnessForTrackId` orchestrates a full analysis re-run for one
|
||||||
|
* track: invalidate waveform gen, clear loudness + backfill state, wipe
|
||||||
|
* server rows, kick a forced seed. The orchestration is what's worth
|
||||||
|
* pinning — each individual helper is already tested in its own module.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => {
|
||||||
|
const authState = {
|
||||||
|
normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness',
|
||||||
|
loudnessTargetLufs: -14,
|
||||||
|
};
|
||||||
|
const playerSnapshot: {
|
||||||
|
currentTrack: { id: string } | null;
|
||||||
|
updateReplayGainForCurrentTrack: ReturnType<typeof vi.fn>;
|
||||||
|
} = {
|
||||||
|
currentTrack: null,
|
||||||
|
updateReplayGainForCurrentTrack: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
authState,
|
||||||
|
playerSnapshot,
|
||||||
|
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => undefined),
|
||||||
|
buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||||
|
bumpWaveformRefreshGenMock: vi.fn(),
|
||||||
|
clearLoudnessCacheMock: vi.fn(),
|
||||||
|
resetBackfillStateMock: vi.fn(),
|
||||||
|
playerSetStateMock: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||||
|
vi.mock('../api/subsonic', () => ({ buildStreamUrl: hoisted.buildStreamUrlMock }));
|
||||||
|
vi.mock('./authStore', () => ({ useAuthStore: { getState: () => hoisted.authState } }));
|
||||||
|
vi.mock('./playerStore', () => ({
|
||||||
|
usePlayerStore: {
|
||||||
|
getState: () => hoisted.playerSnapshot,
|
||||||
|
setState: hoisted.playerSetStateMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('./waveformRefreshGen', () => ({
|
||||||
|
bumpWaveformRefreshGen: hoisted.bumpWaveformRefreshGenMock,
|
||||||
|
}));
|
||||||
|
vi.mock('./loudnessGainCache', () => ({
|
||||||
|
clearLoudnessCacheStateForTrackId: hoisted.clearLoudnessCacheMock,
|
||||||
|
}));
|
||||||
|
vi.mock('./loudnessBackfillState', () => ({
|
||||||
|
resetLoudnessBackfillStateForTrackId: hoisted.resetBackfillStateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { reseedLoudnessForTrackId } from './loudnessReseed';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hoisted.authState.normalizationEngine = 'loudness';
|
||||||
|
hoisted.authState.loudnessTargetLufs = -14;
|
||||||
|
hoisted.playerSnapshot.currentTrack = null;
|
||||||
|
hoisted.invokeMock.mockReset();
|
||||||
|
hoisted.invokeMock.mockResolvedValue(undefined);
|
||||||
|
hoisted.buildStreamUrlMock.mockClear();
|
||||||
|
hoisted.bumpWaveformRefreshGenMock.mockClear();
|
||||||
|
hoisted.clearLoudnessCacheMock.mockClear();
|
||||||
|
hoisted.resetBackfillStateMock.mockClear();
|
||||||
|
hoisted.playerSetStateMock.mockClear();
|
||||||
|
hoisted.playerSnapshot.updateReplayGainForCurrentTrack = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reseedLoudnessForTrackId', () => {
|
||||||
|
it('is a no-op for empty trackId', async () => {
|
||||||
|
await reseedLoudnessForTrackId('');
|
||||||
|
expect(hoisted.invokeMock).not.toHaveBeenCalled();
|
||||||
|
expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when normalization engine isn't loudness", async () => {
|
||||||
|
hoisted.authState.normalizationEngine = 'off';
|
||||||
|
await reseedLoudnessForTrackId('t1');
|
||||||
|
expect(hoisted.invokeMock).not.toHaveBeenCalled();
|
||||||
|
expect(hoisted.bumpWaveformRefreshGenMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs the full reseed pipeline in order', async () => {
|
||||||
|
await reseedLoudnessForTrackId('t1');
|
||||||
|
expect(hoisted.bumpWaveformRefreshGenMock).toHaveBeenCalledWith('t1');
|
||||||
|
expect(hoisted.clearLoudnessCacheMock).toHaveBeenCalledWith('t1');
|
||||||
|
expect(hoisted.resetBackfillStateMock).toHaveBeenCalledWith('t1');
|
||||||
|
const invokeCalls = hoisted.invokeMock.mock.calls.map(c => c[0]);
|
||||||
|
expect(invokeCalls).toEqual([
|
||||||
|
'analysis_delete_waveform_for_track',
|
||||||
|
'analysis_delete_loudness_for_track',
|
||||||
|
'analysis_enqueue_seed_from_url',
|
||||||
|
]);
|
||||||
|
expect(hoisted.invokeMock.mock.calls[2][1]).toEqual({
|
||||||
|
trackId: 't1',
|
||||||
|
url: 'https://mock/stream/t1',
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blanks the seekbar only when the reseed target is the current track', async () => {
|
||||||
|
hoisted.playerSnapshot.currentTrack = { id: 't1' };
|
||||||
|
await reseedLoudnessForTrackId('t1');
|
||||||
|
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
|
||||||
|
expect(setStateCalls).toContainEqual({ waveformBins: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT blank the seekbar when reseeding a different track', async () => {
|
||||||
|
hoisted.playerSnapshot.currentTrack = { id: 'other' };
|
||||||
|
await reseedLoudnessForTrackId('t1');
|
||||||
|
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
|
||||||
|
expect(setStateCalls).not.toContainEqual({ waveformBins: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets live normalization-state to placeholder values', async () => {
|
||||||
|
hoisted.authState.loudnessTargetLufs = -10;
|
||||||
|
await reseedLoudnessForTrackId('t1');
|
||||||
|
const setStateCalls = hoisted.playerSetStateMock.mock.calls.map(c => c[0]);
|
||||||
|
expect(setStateCalls).toContainEqual({
|
||||||
|
normalizationNowDb: null,
|
||||||
|
normalizationTargetLufs: -10,
|
||||||
|
normalizationEngineLive: 'loudness',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues past errors in delete-waveform', async () => {
|
||||||
|
hoisted.invokeMock
|
||||||
|
.mockRejectedValueOnce(new Error('waveform delete failed'))
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
await reseedLoudnessForTrackId('t1');
|
||||||
|
expect(hoisted.invokeMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('continues past errors in delete-loudness', async () => {
|
||||||
|
hoisted.invokeMock
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockRejectedValueOnce(new Error('loudness delete failed'))
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
await reseedLoudnessForTrackId('t1');
|
||||||
|
expect(hoisted.invokeMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows the final enqueue-seed error', async () => {
|
||||||
|
hoisted.invokeMock
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockRejectedValueOnce(new Error('enqueue failed'));
|
||||||
|
await expect(reseedLoudnessForTrackId('t1')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { buildStreamUrl } from '../api/subsonic';
|
||||||
|
import { useAuthStore } from './authStore';
|
||||||
|
import { usePlayerStore } from './playerStore';
|
||||||
|
import { bumpWaveformRefreshGen } from './waveformRefreshGen';
|
||||||
|
import { clearLoudnessCacheStateForTrackId } from './loudnessGainCache';
|
||||||
|
import { resetLoudnessBackfillStateForTrackId } from './loudnessBackfillState';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear down every cached piece of analysis for a track and re-enqueue a
|
||||||
|
* forced reseed. Used by the Settings „Re-analyse this track" action.
|
||||||
|
*
|
||||||
|
* Sequence:
|
||||||
|
* 1. Skip if loudness engine isn't active (re-analysis only makes sense
|
||||||
|
* when normalization actually consumes the result).
|
||||||
|
* 2. Invalidate the waveform refresh generation so any in-flight read
|
||||||
|
* for this id is discarded, and blank the seekbar bins immediately if
|
||||||
|
* the track is current.
|
||||||
|
* 3. Wipe both loudness-cache maps (both forms of the id) + the backfill
|
||||||
|
* retry state.
|
||||||
|
* 4. Reset the live normalization-state to placeholder values so the UI
|
||||||
|
* doesn't show stale dB until the new analysis lands.
|
||||||
|
* 5. Delete the on-disk waveform + loudness rows via Rust.
|
||||||
|
* 6. Re-issue `updateReplayGainForCurrentTrack` so the engine drops
|
||||||
|
* whatever gain it was holding for this track.
|
||||||
|
* 7. Enqueue a forced seed via `analysis_enqueue_seed_from_url`.
|
||||||
|
*
|
||||||
|
* Best-effort throughout — Rust errors are logged but never thrown.
|
||||||
|
*/
|
||||||
|
export async function reseedLoudnessForTrackId(trackId: string): Promise<void> {
|
||||||
|
if (!trackId) return;
|
||||||
|
const auth = useAuthStore.getState();
|
||||||
|
if (auth.normalizationEngine !== 'loudness') return;
|
||||||
|
bumpWaveformRefreshGen(trackId);
|
||||||
|
if (usePlayerStore.getState().currentTrack?.id === trackId) {
|
||||||
|
usePlayerStore.setState({ waveformBins: null });
|
||||||
|
}
|
||||||
|
clearLoudnessCacheStateForTrackId(trackId);
|
||||||
|
resetLoudnessBackfillStateForTrackId(trackId);
|
||||||
|
usePlayerStore.setState({
|
||||||
|
normalizationNowDb: null,
|
||||||
|
normalizationTargetLufs: auth.loudnessTargetLufs,
|
||||||
|
normalizationEngineLive: 'loudness',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await invoke('analysis_delete_waveform_for_track', { trackId });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[psysonic] analysis_delete_waveform_for_track failed:', e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await invoke('analysis_delete_loudness_for_track', { trackId });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[psysonic] analysis_delete_loudness_for_track failed:', e);
|
||||||
|
}
|
||||||
|
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
||||||
|
const url = buildStreamUrl(trackId);
|
||||||
|
try {
|
||||||
|
await invoke('analysis_enqueue_seed_from_url', {
|
||||||
|
trackId,
|
||||||
|
url,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-64
@@ -105,6 +105,15 @@ import {
|
|||||||
setGaplessPreloadingId,
|
setGaplessPreloadingId,
|
||||||
} from './gaplessPreloadState';
|
} from './gaplessPreloadState';
|
||||||
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
||||||
|
import {
|
||||||
|
SEEK_TARGET_GUARD_TIMEOUT_MS,
|
||||||
|
clearSeekTarget,
|
||||||
|
getSeekTarget,
|
||||||
|
getSeekTargetSetAt,
|
||||||
|
setSeekTarget,
|
||||||
|
} from './seekTargetState';
|
||||||
|
import { tryAcquireTogglePlayLock } from './togglePlayLock';
|
||||||
|
import { reseedLoudnessForTrackId } from './loudnessReseed';
|
||||||
|
|
||||||
// Re-export so TauriEventBridge + persistence test keep their existing
|
// Re-export so TauriEventBridge + persistence test keep their existing
|
||||||
// `from './playerStore'` imports.
|
// `from './playerStore'` imports.
|
||||||
@@ -476,11 +485,6 @@ function queueUndoRestoreAudioEngine(opts: {
|
|||||||
|
|
||||||
// Debounce timer for seek slider drags.
|
// Debounce timer for seek slider drags.
|
||||||
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
// Target time of the last seek — blocks stale Rust progress ticks until the
|
|
||||||
// engine has actually caught up to the new position.
|
|
||||||
let seekTarget: number | null = null;
|
|
||||||
let seekTargetSetAt = 0;
|
|
||||||
const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000;
|
|
||||||
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
||||||
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let seekFallbackRetryStartedAt = 0;
|
let seekFallbackRetryStartedAt = 0;
|
||||||
@@ -499,16 +503,6 @@ const STORE_PROGRESS_COMMIT_MIN_DELTA_SEC = 5.0;
|
|||||||
let lastStoreProgressCommitAt = 0;
|
let lastStoreProgressCommitAt = 0;
|
||||||
|
|
||||||
|
|
||||||
function setSeekTarget(seconds: number) {
|
|
||||||
seekTarget = seconds;
|
|
||||||
seekTargetSetAt = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSeekTarget() {
|
|
||||||
seekTarget = null;
|
|
||||||
seekTargetSetAt = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSeekFallbackRetry() {
|
function clearSeekFallbackRetry() {
|
||||||
if (seekFallbackRetryTimer) {
|
if (seekFallbackRetryTimer) {
|
||||||
clearTimeout(seekFallbackRetryTimer);
|
clearTimeout(seekFallbackRetryTimer);
|
||||||
@@ -562,9 +556,6 @@ function scheduleSeekFallbackRetry(trackId: string, seconds: number) {
|
|||||||
}, SEEK_FALLBACK_RETRY_INTERVAL_MS);
|
}, SEEK_FALLBACK_RETRY_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard against rapid double-click play/pause sending two state transitions
|
|
||||||
// to the Rust backend before it has finished the previous one.
|
|
||||||
let togglePlayLock = false;
|
|
||||||
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
|
// ── HTML5 Radio Player ────────────────────────────────────────────────────────
|
||||||
// Internet radio streams are played via a native <audio> element instead of
|
// Internet radio streams are played via a native <audio> element instead of
|
||||||
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
|
// the Rust/Symphonia engine. This gives us browser-native reconnect logic,
|
||||||
@@ -640,46 +631,6 @@ radioAudio.addEventListener('suspend', () => {
|
|||||||
* preload + current-track completion can stack two SQLite reads + two state writes. */
|
* preload + current-track completion can stack two SQLite reads + two state writes. */
|
||||||
const loudnessRefreshInflight = new Map<string, Promise<void>>();
|
const loudnessRefreshInflight = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
async function reseedLoudnessForTrackId(trackId: string) {
|
|
||||||
if (!trackId) return;
|
|
||||||
const auth = useAuthStore.getState();
|
|
||||||
if (auth.normalizationEngine !== 'loudness') return;
|
|
||||||
bumpWaveformRefreshGen(trackId);
|
|
||||||
if (usePlayerStore.getState().currentTrack?.id === trackId) {
|
|
||||||
usePlayerStore.setState({ waveformBins: null });
|
|
||||||
}
|
|
||||||
clearLoudnessCacheStateForTrackId(trackId);
|
|
||||||
resetLoudnessBackfillStateForTrackId(trackId);
|
|
||||||
if (auth.normalizationEngine === 'loudness') {
|
|
||||||
usePlayerStore.setState({
|
|
||||||
normalizationNowDb: null,
|
|
||||||
normalizationTargetLufs: auth.loudnessTargetLufs,
|
|
||||||
normalizationEngineLive: 'loudness',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await invoke('analysis_delete_waveform_for_track', { trackId });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[psysonic] analysis_delete_waveform_for_track failed:', e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await invoke('analysis_delete_loudness_for_track', { trackId });
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[psysonic] analysis_delete_loudness_for_track failed:', e);
|
|
||||||
}
|
|
||||||
usePlayerStore.getState().updateReplayGainForCurrentTrack();
|
|
||||||
const url = buildStreamUrl(trackId);
|
|
||||||
try {
|
|
||||||
await invoke('analysis_enqueue_seed_from_url', {
|
|
||||||
trackId,
|
|
||||||
url,
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[psysonic] analysis_enqueue_seed_from_url (reseed) failed:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshWaveformForTrack(trackId: string) {
|
async function refreshWaveformForTrack(trackId: string) {
|
||||||
if (!trackId) return;
|
if (!trackId) return;
|
||||||
const gen = getWaveformRefreshGen(trackId);
|
const gen = getWaveformRefreshGen(trackId);
|
||||||
@@ -828,10 +779,11 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
// 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.
|
||||||
if (seekTarget !== null) {
|
const activeSeekTarget = getSeekTarget();
|
||||||
if (Math.abs(current_time - seekTarget) > 2.0) {
|
if (activeSeekTarget !== null) {
|
||||||
|
if (Math.abs(current_time - activeSeekTarget) > 2.0) {
|
||||||
// If a seek command hangs while streaming is stalled, do not freeze UI.
|
// If a seek command hangs while streaming is stalled, do not freeze UI.
|
||||||
if (Date.now() - seekTargetSetAt <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
|
if (Date.now() - getSeekTargetSetAt() <= SEEK_TARGET_GUARD_TIMEOUT_MS) return;
|
||||||
clearSeekTarget();
|
clearSeekTarget();
|
||||||
} else {
|
} else {
|
||||||
clearSeekTarget();
|
clearSeekTarget();
|
||||||
@@ -2515,9 +2467,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
togglePlay: () => {
|
togglePlay: () => {
|
||||||
if (togglePlayLock) return;
|
if (!tryAcquireTogglePlayLock()) return;
|
||||||
togglePlayLock = true;
|
|
||||||
setTimeout(() => { togglePlayLock = false; }, 300);
|
|
||||||
const { isPlaying } = get();
|
const { isPlaying } = get();
|
||||||
isPlaying ? get().pause() : get().resume();
|
isPlaying ? get().pause() : get().resume();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
SEEK_TARGET_GUARD_TIMEOUT_MS,
|
||||||
|
_resetSeekTargetStateForTest,
|
||||||
|
clearSeekTarget,
|
||||||
|
getSeekTarget,
|
||||||
|
getSeekTargetSetAt,
|
||||||
|
setSeekTarget,
|
||||||
|
} from './seekTargetState';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
_resetSeekTargetStateForTest();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SEEK_TARGET_GUARD_TIMEOUT_MS', () => {
|
||||||
|
it('is the value the progress handler expects', () => {
|
||||||
|
expect(SEEK_TARGET_GUARD_TIMEOUT_MS).toBe(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seekTargetState', () => {
|
||||||
|
it('returns null + 0 before any seek', () => {
|
||||||
|
expect(getSeekTarget()).toBeNull();
|
||||||
|
expect(getSeekTargetSetAt()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores target + timestamp on set', () => {
|
||||||
|
setSeekTarget(42);
|
||||||
|
expect(getSeekTarget()).toBe(42);
|
||||||
|
expect(getSeekTargetSetAt()).toBe(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates timestamp when called again', () => {
|
||||||
|
setSeekTarget(10);
|
||||||
|
const first = getSeekTargetSetAt();
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
setSeekTarget(20);
|
||||||
|
expect(getSeekTarget()).toBe(20);
|
||||||
|
expect(getSeekTargetSetAt()).toBeGreaterThan(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearSeekTarget resets both fields', () => {
|
||||||
|
setSeekTarget(42);
|
||||||
|
clearSeekTarget();
|
||||||
|
expect(getSeekTarget()).toBeNull();
|
||||||
|
expect(getSeekTargetSetAt()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearSeekTarget is a no-op when nothing is set', () => {
|
||||||
|
expect(() => clearSeekTarget()).not.toThrow();
|
||||||
|
expect(getSeekTarget()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Tracks the target time of the most recent user seek. The progress
|
||||||
|
* handler reads this to suppress stale Rust progress ticks until the
|
||||||
|
* engine has actually caught up to the new position — without the guard,
|
||||||
|
* the slider snaps back briefly because Rust keeps emitting the old
|
||||||
|
* position until the seek finishes propagating.
|
||||||
|
*
|
||||||
|
* Lifetime: set when a seek IPC is issued, cleared once progress reaches
|
||||||
|
* within ~2 s of the target or when the guard timeout (5 s) elapses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SEEK_TARGET_GUARD_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
let seekTarget: number | null = null;
|
||||||
|
let seekTargetSetAt = 0;
|
||||||
|
|
||||||
|
export function setSeekTarget(seconds: number): void {
|
||||||
|
seekTarget = seconds;
|
||||||
|
seekTargetSetAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSeekTarget(): void {
|
||||||
|
seekTarget = null;
|
||||||
|
seekTargetSetAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSeekTarget(): number | null {
|
||||||
|
return seekTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSeekTargetSetAt(): number {
|
||||||
|
return seekTargetSetAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: reset both mutables so each spec starts fresh. */
|
||||||
|
export function _resetSeekTargetStateForTest(): void {
|
||||||
|
seekTarget = null;
|
||||||
|
seekTargetSetAt = 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
_resetTogglePlayLockForTest,
|
||||||
|
tryAcquireTogglePlayLock,
|
||||||
|
} from './togglePlayLock';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
_resetTogglePlayLockForTest();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tryAcquireTogglePlayLock', () => {
|
||||||
|
it('returns true on first acquire', () => {
|
||||||
|
expect(tryAcquireTogglePlayLock()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when already held', () => {
|
||||||
|
tryAcquireTogglePlayLock();
|
||||||
|
expect(tryAcquireTogglePlayLock()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-releases after the default 300 ms window', () => {
|
||||||
|
tryAcquireTogglePlayLock();
|
||||||
|
vi.advanceTimersByTime(299);
|
||||||
|
expect(tryAcquireTogglePlayLock()).toBe(false);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(tryAcquireTogglePlayLock()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a custom lock duration', () => {
|
||||||
|
tryAcquireTogglePlayLock(1000);
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(tryAcquireTogglePlayLock()).toBe(false);
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(tryAcquireTogglePlayLock()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_resetTogglePlayLockForTest force-releases', () => {
|
||||||
|
tryAcquireTogglePlayLock();
|
||||||
|
_resetTogglePlayLockForTest();
|
||||||
|
expect(tryAcquireTogglePlayLock()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Short cooldown guard for the play/pause toggle so a rapid double-click
|
||||||
|
* doesn't send two state transitions to the Rust backend before the first
|
||||||
|
* one has finished. Held for 300 ms by default — long enough to absorb a
|
||||||
|
* double-click + the engine round-trip, short enough that intentional
|
||||||
|
* fast toggles still feel responsive.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
*
|
||||||
|
* if (!tryAcquireTogglePlayLock()) return;
|
||||||
|
* // ... perform toggle ...
|
||||||
|
*
|
||||||
|
* The lock auto-releases on a timer; no manual release call needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let togglePlayLock = false;
|
||||||
|
const DEFAULT_LOCK_MS = 300;
|
||||||
|
|
||||||
|
export function tryAcquireTogglePlayLock(durationMs: number = DEFAULT_LOCK_MS): boolean {
|
||||||
|
if (togglePlayLock) return false;
|
||||||
|
togglePlayLock = true;
|
||||||
|
setTimeout(() => { togglePlayLock = false; }, durationMs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: force-release the lock so each spec starts fresh. */
|
||||||
|
export function _resetTogglePlayLockForTest(): void {
|
||||||
|
togglePlayLock = false;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user