From a1d7cf330d2eb175fd19ae7ae418c437cdcb413e Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 12:35:01 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.7=20=E2=80=94=20extract?= =?UTF-8?q?=20two=20small=20file-private=20helpers=20(#570)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `emitNormalizationDebug` (debug-mode trace forwarder, 15+ internal call sites) and `isInOrbitSession` (Orbit-active guard used by next() and the async fallback paths to suppress local queue extensions, 5 call sites) move into dedicated modules under src/store/. Both were file-private — no caller-side changes outside playerStore's own imports. playerStore 3434 → 3415 LOC. --- src/store/normalizationDebug.test.ts | 51 +++++++++++++++++++++++++++ src/store/normalizationDebug.ts | 19 ++++++++++ src/store/orbitSession.test.ts | 52 ++++++++++++++++++++++++++++ src/store/orbitSession.ts | 17 +++++++++ src/store/playerStore.ts | 23 ++---------- 5 files changed, 141 insertions(+), 21 deletions(-) create mode 100644 src/store/normalizationDebug.test.ts create mode 100644 src/store/normalizationDebug.ts create mode 100644 src/store/orbitSession.test.ts create mode 100644 src/store/orbitSession.ts diff --git a/src/store/normalizationDebug.test.ts b/src/store/normalizationDebug.test.ts new file mode 100644 index 00000000..63dbbda3 --- /dev/null +++ b/src/store/normalizationDebug.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { authState, invokeMock } = vi.hoisted(() => ({ + authState: { loggingMode: 'off' as 'off' | 'debug' | string }, + invokeMock: vi.fn(async (_cmd: string, _args?: Record) => undefined), +})); + +vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })); +vi.mock('./authStore', () => ({ useAuthStore: { getState: () => authState } })); + +import { emitNormalizationDebug } from './normalizationDebug'; + +beforeEach(() => { + authState.loggingMode = 'off'; + invokeMock.mockClear(); + invokeMock.mockResolvedValue(undefined); +}); + +describe('emitNormalizationDebug', () => { + it('is a no-op when logging mode is not debug', () => { + emitNormalizationDebug('refresh:start', { trackId: 't1' }); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it('forwards a JSON payload to frontend_debug_log in debug mode', () => { + authState.loggingMode = 'debug'; + emitNormalizationDebug('refresh:start', { trackId: 't1' }); + expect(invokeMock).toHaveBeenCalledTimes(1); + const [cmd, args] = invokeMock.mock.calls[0]; + expect(cmd).toBe('frontend_debug_log'); + expect(args).toMatchObject({ + scope: 'normalization', + message: JSON.stringify({ step: 'refresh:start', details: { trackId: 't1' } }), + }); + }); + + it('serializes calls without details too', () => { + authState.loggingMode = 'debug'; + emitNormalizationDebug('plain-step'); + const args = invokeMock.mock.calls[0][1] as { message: string }; + expect(JSON.parse(args.message)).toEqual({ step: 'plain-step' }); + }); + + it('swallows invoke rejections (best-effort instrumentation)', async () => { + authState.loggingMode = 'debug'; + invokeMock.mockRejectedValueOnce(new Error('rust busy')); + expect(() => emitNormalizationDebug('refresh:start')).not.toThrow(); + // Give the rejected promise a tick to settle without throwing. + await Promise.resolve(); + }); +}); diff --git a/src/store/normalizationDebug.ts b/src/store/normalizationDebug.ts new file mode 100644 index 00000000..3e2cab1b --- /dev/null +++ b/src/store/normalizationDebug.ts @@ -0,0 +1,19 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useAuthStore } from './authStore'; + +/** + * Forward a structured normalization-pipeline trace to the Rust-side debug + * log file when Settings → Logging is set to **Debug**. A no-op otherwise, + * so the dozens of call sites in `playerStore` (refresh / backfill / engine + * sync / track-switch instrumentation) carry zero cost in normal mode. + * + * Errors invoking the Rust command are swallowed — this is best-effort + * instrumentation, not a playback dependency. + */ +export function emitNormalizationDebug(step: string, details?: Record): void { + if (useAuthStore.getState().loggingMode !== 'debug') return; + void invoke('frontend_debug_log', { + scope: 'normalization', + message: JSON.stringify({ step, details }), + }).catch(() => {}); +} diff --git a/src/store/orbitSession.test.ts b/src/store/orbitSession.test.ts new file mode 100644 index 00000000..cc5ae624 --- /dev/null +++ b/src/store/orbitSession.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { orbitState } = vi.hoisted(() => ({ + orbitState: { + role: null as 'host' | 'guest' | null, + phase: 'idle' as 'idle' | 'starting' | 'joining' | 'active' | 'ending' | 'ended' | 'error', + }, +})); + +vi.mock('./orbitStore', () => ({ + useOrbitStore: { getState: () => orbitState }, +})); + +import { isInOrbitSession } from './orbitSession'; + +beforeEach(() => { + orbitState.role = null; + orbitState.phase = 'idle'; +}); + +describe('isInOrbitSession', () => { + it('returns false when role is null', () => { + expect(isInOrbitSession()).toBe(false); + }); + + it.each(['active', 'joining', 'starting'] as const)( + "returns true for role='host', phase='%s'", + phase => { + orbitState.role = 'host'; + orbitState.phase = phase; + expect(isInOrbitSession()).toBe(true); + }, + ); + + it.each(['active', 'joining', 'starting'] as const)( + "returns true for role='guest', phase='%s'", + phase => { + orbitState.role = 'guest'; + orbitState.phase = phase; + expect(isInOrbitSession()).toBe(true); + }, + ); + + it.each(['idle', 'ending', 'ended', 'error'] as const)( + "returns false for transient/inactive phase='%s'", + phase => { + orbitState.role = 'host'; + orbitState.phase = phase; + expect(isInOrbitSession()).toBe(false); + }, + ); +}); diff --git a/src/store/orbitSession.ts b/src/store/orbitSession.ts new file mode 100644 index 00000000..8f8d2fea --- /dev/null +++ b/src/store/orbitSession.ts @@ -0,0 +1,17 @@ +import { useOrbitStore } from './orbitStore'; + +/** + * True when the user is part of an Orbit session (any role, any phase short + * of `idle` / `error` / `ended`). Used by `next()` and its async fallback + * callbacks to suppress local queue-extension paths (radio top-up, infinite + * queue, queue-exhausted refill) — those would either pop the + * `orbitBulkGuard` modal or silently inject tracks the host didn't pick. + * Also called inside in-flight `.then()` callbacks so a fetch scheduled + * just before the user joined Orbit doesn't fire a `playTrack` after the + * join. + */ +export function isInOrbitSession(): boolean { + const o = useOrbitStore.getState(); + if (o.role !== 'host' && o.role !== 'guest') return false; + return o.phase === 'active' || o.phase === 'joining' || o.phase === 'starting'; +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index ce034d52..fc96b14f 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -45,6 +45,8 @@ import { shouldRebindPlaybackToHotCache, } from './playbackUrlRouting'; import { deriveNormalizationSnapshot } from './normalizationSnapshot'; +import { emitNormalizationDebug } from './normalizationDebug'; +import { isInOrbitSession } from './orbitSession'; // Re-export the playback-progress public surface so existing call sites // (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, @@ -323,19 +325,6 @@ let infiniteQueueFetching = false; // Guard against concurrent radio top-up fetches. let radioFetching = false; -/** True when the user is part of an Orbit session (any role, any phase - * short of `idle` / `error` / `ended`). Used by `next()` and its async - * fallback callbacks to suppress local queue-extension paths (radio - * top-up, infinite-queue, queue-exhausted refill) — those would either - * pop the orbitBulkGuard modal or silently inject tracks the host - * didn't pick. The helper is also called inside in-flight `.then()` - * callbacks so a fetch scheduled just before the user joined Orbit - * doesn't fire a `playTrack` after the join. */ -function isInOrbitSession(): boolean { - const o = useOrbitStore.getState(); - if (o.role !== 'host' && o.role !== 'guest') return false; - return o.phase === 'active' || o.phase === 'joining' || o.phase === 'starting'; -} // Artist ID used to start the current radio session — persists across track // advances so proactive loading works even when songs lack artistId. let currentRadioArtistId: string | null = null; @@ -425,14 +414,6 @@ function queueUndoRestoreAudioEngine(opts: { touchHotCacheOnPlayback(track.id, authState.activeServerId ?? ''); } -function emitNormalizationDebug(step: string, details?: Record) { - if (useAuthStore.getState().loggingMode !== 'debug') return; - void invoke('frontend_debug_log', { - scope: 'normalization', - message: JSON.stringify({ step, details }), - }).catch(() => {}); -} - // Debounce timer for seek slider drags. let seekDebounce: ReturnType | null = null; // Target time of the last seek — blocks stale Rust progress ticks until the