From c10eabe114783bc8f2302b6df233281b48edb6c5 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 16:45:57 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.21=20=E2=80=94=20extract?= =?UTF-8?q?=20playback-progress=20throttle=20timestamps=20(#585)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three time-based throttles used by the audio-progress handler move into `src/store/playbackThrottles.ts`: - **Live progress emit** (1.5 s / 0.9 s position delta) — feeds the high-frequency pub/sub - **Store progress commit** (20 s / 5 s position delta) — writes the Zustand store - **Normalization UI update** (120 ms) — live dB readout throttle Module owns three private mutables, exports five constants (the four window/delta gates + NORMALIZATION_UI_THROTTLE_MS) and six accessors (get / mark for each), plus `resetProgressEmitThrottles` for the track-boundary reset path that handleAudioPlaying calls. playerStore's six direct-access sites + two reset writes collapse to imports. Behaviour preserved verbatim. 13 focused tests pin each throttle's get/mark cycle, the partial reset (progress only, not normalization), and constant values. playerStore +6 LOC net (more import surface than freed mutables) — shrinkage will come back on the next bigger encapsulation; this PR's win is logical separation + testability rather than line count. --- src/store/playbackThrottles.test.ts | 77 +++++++++++++++++++++++++++++ src/store/playbackThrottles.ts | 64 ++++++++++++++++++++++++ src/store/playerStore.ts | 36 ++++++++------ 3 files changed, 162 insertions(+), 15 deletions(-) create mode 100644 src/store/playbackThrottles.test.ts create mode 100644 src/store/playbackThrottles.ts diff --git a/src/store/playbackThrottles.test.ts b/src/store/playbackThrottles.test.ts new file mode 100644 index 00000000..0c75a875 --- /dev/null +++ b/src/store/playbackThrottles.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + LIVE_PROGRESS_EMIT_MIN_DELTA_SEC, + LIVE_PROGRESS_EMIT_MIN_MS, + NORMALIZATION_UI_THROTTLE_MS, + STORE_PROGRESS_COMMIT_MIN_DELTA_SEC, + STORE_PROGRESS_COMMIT_MIN_MS, + _resetPlaybackThrottlesForTest, + getLastLiveProgressEmitAt, + getLastNormalizationUiUpdateAtMs, + getLastStoreProgressCommitAt, + markLiveProgressEmit, + markNormalizationUiUpdate, + markStoreProgressCommit, + resetProgressEmitThrottles, +} from './playbackThrottles'; + +afterEach(() => { + _resetPlaybackThrottlesForTest(); +}); + +describe('constants', () => { + it('match the values the runtime expects', () => { + expect(LIVE_PROGRESS_EMIT_MIN_MS).toBe(1500); + expect(LIVE_PROGRESS_EMIT_MIN_DELTA_SEC).toBe(0.9); + expect(STORE_PROGRESS_COMMIT_MIN_MS).toBe(20_000); + expect(STORE_PROGRESS_COMMIT_MIN_DELTA_SEC).toBe(5.0); + expect(NORMALIZATION_UI_THROTTLE_MS).toBe(120); + }); +}); + +describe('throttle accessors', () => { + it('all start at 0', () => { + expect(getLastLiveProgressEmitAt()).toBe(0); + expect(getLastStoreProgressCommitAt()).toBe(0); + expect(getLastNormalizationUiUpdateAtMs()).toBe(0); + }); + + it('mark + get round-trip independently for each throttle', () => { + markLiveProgressEmit(1000); + markStoreProgressCommit(2000); + markNormalizationUiUpdate(3000); + expect(getLastLiveProgressEmitAt()).toBe(1000); + expect(getLastStoreProgressCommitAt()).toBe(2000); + expect(getLastNormalizationUiUpdateAtMs()).toBe(3000); + }); + + it('mark overwrites a previous value', () => { + markLiveProgressEmit(1000); + markLiveProgressEmit(2000); + expect(getLastLiveProgressEmitAt()).toBe(2000); + }); +}); + +describe('resetProgressEmitThrottles', () => { + it('zeros both progress throttles but leaves normalization UI alone', () => { + markLiveProgressEmit(1000); + markStoreProgressCommit(2000); + markNormalizationUiUpdate(3000); + resetProgressEmitThrottles(); + expect(getLastLiveProgressEmitAt()).toBe(0); + expect(getLastStoreProgressCommitAt()).toBe(0); + expect(getLastNormalizationUiUpdateAtMs()).toBe(3000); + }); +}); + +describe('_resetPlaybackThrottlesForTest', () => { + it('zeros all three timestamps', () => { + markLiveProgressEmit(1); + markStoreProgressCommit(2); + markNormalizationUiUpdate(3); + _resetPlaybackThrottlesForTest(); + expect(getLastLiveProgressEmitAt()).toBe(0); + expect(getLastStoreProgressCommitAt()).toBe(0); + expect(getLastNormalizationUiUpdateAtMs()).toBe(0); + }); +}); diff --git a/src/store/playbackThrottles.ts b/src/store/playbackThrottles.ts new file mode 100644 index 00000000..f0c4f483 --- /dev/null +++ b/src/store/playbackThrottles.ts @@ -0,0 +1,64 @@ +/** + * Three time-based throttles used by the audio-progress handler so it + * doesn't saturate the WebView2 renderer with redundant emits / setState + * commits on a noisy `audio:progress` stream: + * + * - **Live progress emit** — feeds the high-frequency `playbackProgress` + * pub/sub. Gate: 1.5 s elapsed OR ≥0.9 s position delta. + * - **Store progress commit** — writes the Zustand player store. Gate: + * 20 s elapsed OR ≥5 s position delta. Heavier than the live emit + * because subscribers to the store re-render. + * - **Normalization UI update** — 120 ms throttle on the live dB readout + * written to the store during a track. + * + * The progress emit + commit pair share `resetProgressEmitThrottles` so + * the runtime can force a fresh emit/commit immediately after a track + * boundary (`handleAudioPlaying`). + */ + +export const LIVE_PROGRESS_EMIT_MIN_MS = 1500; +export const LIVE_PROGRESS_EMIT_MIN_DELTA_SEC = 0.9; +export const STORE_PROGRESS_COMMIT_MIN_MS = 20_000; +export const STORE_PROGRESS_COMMIT_MIN_DELTA_SEC = 5.0; +export const NORMALIZATION_UI_THROTTLE_MS = 120; + +let lastLiveProgressEmitAt = 0; +let lastStoreProgressCommitAt = 0; +let lastNormalizationUiUpdateAtMs = 0; + +export function getLastLiveProgressEmitAt(): number { + return lastLiveProgressEmitAt; +} + +export function markLiveProgressEmit(nowMs: number): void { + lastLiveProgressEmitAt = nowMs; +} + +export function getLastStoreProgressCommitAt(): number { + return lastStoreProgressCommitAt; +} + +export function markStoreProgressCommit(nowMs: number): void { + lastStoreProgressCommitAt = nowMs; +} + +export function getLastNormalizationUiUpdateAtMs(): number { + return lastNormalizationUiUpdateAtMs; +} + +export function markNormalizationUiUpdate(nowMs: number): void { + lastNormalizationUiUpdateAtMs = nowMs; +} + +/** Reset the two progress throttles — used by `handleAudioPlaying` so the first emit + commit after a track boundary go through immediately. */ +export function resetProgressEmitThrottles(): void { + lastLiveProgressEmitAt = 0; + lastStoreProgressCommitAt = 0; +} + +/** Test-only: wipe all three timestamps so each spec starts fresh. */ +export function _resetPlaybackThrottlesForTest(): void { + lastLiveProgressEmitAt = 0; + lastStoreProgressCommitAt = 0; + lastNormalizationUiUpdateAtMs = 0; +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index d17099d2..8e864599 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -106,6 +106,20 @@ import { setRadioVolume, stopRadio, } from './radioPlayer'; +import { + LIVE_PROGRESS_EMIT_MIN_DELTA_SEC, + LIVE_PROGRESS_EMIT_MIN_MS, + NORMALIZATION_UI_THROTTLE_MS, + STORE_PROGRESS_COMMIT_MIN_DELTA_SEC, + STORE_PROGRESS_COMMIT_MIN_MS, + getLastLiveProgressEmitAt, + getLastNormalizationUiUpdateAtMs, + getLastStoreProgressCommitAt, + markLiveProgressEmit, + markNormalizationUiUpdate, + markStoreProgressCommit, + resetProgressEmitThrottles, +} from './playbackThrottles'; // Re-export so TauriEventBridge + persistence test keep their existing // `from './playerStore'` imports. @@ -380,7 +394,6 @@ let currentRadioArtistId: string | null = null; // the queue and the next Last.fm/topSongs response could re-add it. Reset // on `setRadioArtistId(other)` and on `clearQueue()`. Issue #500. let radioSessionSeenIds = new Set(); -let lastNormalizationUiUpdateAtMs = 0; /** Reload Rust audio to match a queue-undo snapshot (Zustand alone does not move the engine). */ function queueUndoRestoreAudioEngine(opts: { @@ -469,12 +482,6 @@ let seekFallbackVisualTarget: { trackId: string; seconds: number; setAtMs: numbe const SEEK_FALLBACK_VISUAL_GUARD_MS = 1600; const SEEK_FALLBACK_RETRY_INTERVAL_MS = 180; const SEEK_FALLBACK_RETRY_MAX_MS = 6000; -const LIVE_PROGRESS_EMIT_MIN_MS = 1500; -const LIVE_PROGRESS_EMIT_MIN_DELTA_SEC = 0.9; -let lastLiveProgressEmitAt = 0; -const STORE_PROGRESS_COMMIT_MIN_MS = 20_000; -const STORE_PROGRESS_COMMIT_MIN_DELTA_SEC = 5.0; -let lastStoreProgressCommitAt = 0; function clearSeekFallbackRetry() { @@ -546,8 +553,7 @@ function prefetchLoudnessForEnqueuedTracks( function handleAudioPlaying(_duration: number) { setDeferHotCachePrefetch(false); - lastLiveProgressEmitAt = 0; - lastStoreProgressCommitAt = 0; + resetProgressEmitThrottles(); usePlayerStore.setState({ isPlaying: true }); } @@ -606,7 +612,7 @@ function handleAudioProgress(current_time: number, duration: number) { const live = getPlaybackProgressSnapshot(); const liveTimeDelta = Math.abs(live.currentTime - displayTime); if ( - nowLive - lastLiveProgressEmitAt >= LIVE_PROGRESS_EMIT_MIN_MS || + nowLive - getLastLiveProgressEmitAt() >= LIVE_PROGRESS_EMIT_MIN_MS || liveTimeDelta >= LIVE_PROGRESS_EMIT_MIN_DELTA_SEC || seekFallbackVisualTarget != null ) { @@ -615,7 +621,7 @@ function handleAudioProgress(current_time: number, duration: number) { progress, buffered: 0, }); - lastLiveProgressEmitAt = nowLive; + markLiveProgressEmit(nowLive); } } // Heartbeat: push current position to the server every 15 s while playing so @@ -644,11 +650,11 @@ function handleAudioProgress(current_time: number, duration: number) { const commitDelta = Math.abs(store.currentTime - displayTime); const shouldCommitStore = seekFallbackVisualTarget != null || - nowCommit - lastStoreProgressCommitAt >= STORE_PROGRESS_COMMIT_MIN_MS || + nowCommit - getLastStoreProgressCommitAt() >= STORE_PROGRESS_COMMIT_MIN_MS || commitDelta >= STORE_PROGRESS_COMMIT_MIN_DELTA_SEC; if (shouldCommitStore) { usePlayerStore.setState({ currentTime: displayTime, progress, buffered: 0 }); - lastStoreProgressCommitAt = nowCommit; + markStoreProgressCommit(nowCommit); } // Pre-buffer / pre-chain next track based on preload mode and crossfade. @@ -1002,12 +1008,12 @@ export function initAudioListeners(): () => void { && prev.normalizationNowDb == null; if ( !isFirstNumericGain - && nowMs - lastNormalizationUiUpdateAtMs < 120 + && nowMs - getLastNormalizationUiUpdateAtMs() < NORMALIZATION_UI_THROTTLE_MS && engine === prev.normalizationEngineLive ) { return; } - lastNormalizationUiUpdateAtMs = nowMs; + markNormalizationUiUpdate(nowMs); emitNormalizationDebug('event:audio:normalization-state', { trackId: usePlayerStore.getState().currentTrack?.id ?? null, payload,