refactor(player): E.7 — extract two small file-private helpers (#570)

`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.
This commit is contained in:
Frank Stellmacher
2026-05-12 12:35:01 +02:00
committed by GitHub
parent 81b161a418
commit a1d7cf330d
5 changed files with 141 additions and 21 deletions
+51
View File
@@ -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<string, unknown>) => 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();
});
});
+19
View File
@@ -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<string, unknown>): void {
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', {
scope: 'normalization',
message: JSON.stringify({ step, details }),
}).catch(() => {});
}
+52
View File
@@ -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);
},
);
});
+17
View File
@@ -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';
}
+2 -21
View File
@@ -45,6 +45,8 @@ import {
shouldRebindPlaybackToHotCache, shouldRebindPlaybackToHotCache,
} from './playbackUrlRouting'; } from './playbackUrlRouting';
import { deriveNormalizationSnapshot } from './normalizationSnapshot'; import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import { emitNormalizationDebug } from './normalizationDebug';
import { isInOrbitSession } from './orbitSession';
// Re-export the playback-progress public surface so existing call sites // Re-export the playback-progress public surface so existing call sites
// (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, // (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView,
@@ -323,19 +325,6 @@ let infiniteQueueFetching = false;
// Guard against concurrent radio top-up fetches. // Guard against concurrent radio top-up fetches.
let radioFetching = false; 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 // Artist ID used to start the current radio session — persists across track
// advances so proactive loading works even when songs lack artistId. // advances so proactive loading works even when songs lack artistId.
let currentRadioArtistId: string | null = null; let currentRadioArtistId: string | null = null;
@@ -425,14 +414,6 @@ function queueUndoRestoreAudioEngine(opts: {
touchHotCacheOnPlayback(track.id, authState.activeServerId ?? ''); touchHotCacheOnPlayback(track.id, authState.activeServerId ?? '');
} }
function emitNormalizationDebug(step: string, details?: Record<string, unknown>) {
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. // 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 // Target time of the last seek — blocks stale Rust progress ticks until the