refactor(player): E.42 — migrate playerStore re-exports to direct imports (#607)

Migrates ~74 call sites away from the playerStore re-export shims that
were kept during M0–E.41 to avoid touching 30+ imports per PR. Now
that the bigger refactor work is done, each helper goes back to its
real home:

- `initAudioListeners`, `installQueueUndoHotkey`, `flushPlayQueuePosition`
  → from their own store modules
- `getPlaybackProgressSnapshot`, `subscribePlaybackProgress`,
  `PlaybackProgressSnapshot` → from `playbackProgress`
- `resolveReplayGainDb`, `shuffleArray`, `songToTrack`
  → from `utils/*`
- `_resetQueueUndoStacksForTest`, `consumePendingQueueListScrollTop`,
  `registerQueueListScrollTopReader` → from `queueUndo`
- `PlayerState`, `Track` types → from `playerStoreTypes`

Drops the corresponding 13 re-export stubs from `playerStore.ts` and
the now-unused imports. Also drops dead section banners + per-wrapper
comments above one-line action delegates. Trims one stale "(separate
PR)" note in `transportLightActions.ts` since that follow-up landed
in E.39.

`playerStore.ts`: 180 → 112 LOC (−68). Down from Phase E's starting
3732 LOC.

`bootstrap.test.ts` mock target updated from `../store/playerStore`
to `../store/queueUndoHotkey` to keep the spy reachable after the
import change.
This commit is contained in:
Frank Stellmacher
2026-05-12 22:46:13 +02:00
committed by GitHub
parent e92535a5f5
commit 9fac6eb490
81 changed files with 155 additions and 236 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
import type { Track } from './playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { lastfmGetTrackLoved, lastfmScrobble, lastfmUpdateNowPlaying } from '../api/lastfm';
import { reportNowPlaying, scrobbleSong } from '../api/subsonic';
@@ -44,7 +45,7 @@ import {
import {
playbackSourceHintForResolvedUrl,
} from './playbackUrlRouting';
import { usePlayerStore, type Track } from './playerStore';
import { usePlayerStore } from './playerStore';
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
import {
flushQueueSyncToServer,
+1 -1
View File
@@ -1,8 +1,8 @@
import type { Track } from './playerStoreTypes';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { isHotCachePreviousTrackUnderGrace } from '../utils/hotCacheGate';
import type { Track } from './playerStore';
import { emitAnalysisStorageChanged } from './analysisSync';
import { useAuthStore } from './authStore';
+5 -17
View File
@@ -12,23 +12,11 @@ type SetState = (
type GetState = () => PlayerState;
/**
* Four Last.fm love-related actions, factored out of the playerStore
* `create()` body so the action set can be tested + reasoned about
* separately:
*
* - `toggleLastfmLove` — flip the current track's love state on the
* server, write through to the local cache map keyed by
* `${title}::${artist}` so other queue rows showing the same song
* update too.
* - `setLastfmLoved` — force-set the boolean (used by the
* `track:lastfm-loved` SSE-style event). Updates the cache when a
* current track exists.
* - `setLastfmLovedForSong` — write the cache for an arbitrary
* title/artist pair (used by the QueuePanel love button on
* not-yet-current tracks).
* - `syncLastfmLovedTracks` — startup-time bulk fetch of the user's
* loved-tracks list, merged into the local cache (local likes win
* on conflict) plus a recompute of the current track's flag.
* Four Last.fm love-related actions. The `lastfmLovedCache` is a map
* keyed by `${title}::${artist}` (not by track id) so other queue rows
* showing the same song update too when one is loved/unloved.
* `syncLastfmLovedTracks` merges the server's loved list with local
* cache — local likes win on conflict.
*/
export function createLastfmActions(set: SetState, get: GetState): Pick<
PlayerState,
+1 -1
View File
@@ -4,8 +4,8 @@
* = current track + next `LOUDNESS_BACKFILL_WINDOW_AHEAD` entries, with
* duplicates collapsed.
*/
import type { Track } from './playerStoreTypes';
import { describe, expect, it } from 'vitest';
import type { Track } from './playerStore';
import {
LOUDNESS_BACKFILL_WINDOW_AHEAD,
collectLoudnessBackfillWindowTrackIds,
+1 -2
View File
@@ -1,5 +1,4 @@
import type { Track } from './playerStore';
import type { Track } from './playerStoreTypes';
/**
* After a bulk enqueue (queue replace, append-many, lucky-mix) the runtime
* warms the loudness cache for the current track + the next N entries so
+1 -2
View File
@@ -4,9 +4,8 @@
* guard, the window collection, and the no-sync-engine flag on each
* refresh call.
*/
import type { Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from './playerStore';
const hoisted = vi.hoisted(() => {
const auth = { normalizationEngine: 'loudness' as 'off' | 'replaygain' | 'loudness' };
const player = { currentTrack: null as Track | null };
+2 -2
View File
@@ -1,8 +1,8 @@
import type { Track } from './playerStoreTypes';
import { useAuthStore } from './authStore';
import { collectLoudnessBackfillWindowTrackIds } from './loudnessBackfillWindow';
import { refreshLoudnessForTrack } from './loudnessRefresh';
import { usePlayerStore, type Track } from './playerStore';
import { usePlayerStore } from './playerStore';
/**
* After a bulk enqueue (queue replace, append-many, lucky-mix) warm the
* loudness cache for the current track + the next N entries so the
+1 -2
View File
@@ -4,9 +4,8 @@
* album-mode resolution). useAuthStore is mocked through a hoisted state
* object so each test can flip flags without rebuilding the store.
*/
import type { Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from './playerStore';
const { authState } = vi.hoisted(() => ({
authState: {
normalizationEngine: 'off' as 'off' | 'replaygain' | 'loudness',
+1 -2
View File
@@ -1,7 +1,6 @@
import type { PlayerState, Track } from './playerStoreTypes';
import { useAuthStore } from './authStore';
import { resolveReplayGainDb } from '../utils/resolveReplayGainDb';
import type { PlayerState, Track } from './playerStore';
/**
* Compute the normalization fields that should land in the next state commit
* when the runtime switches tracks or rewrites the queue. Three branches:
+2 -1
View File
@@ -6,6 +6,7 @@
* regression test from §4.2 of the pre-refactor testing plan v2 — the
* cleanup function returned by `initAudioListeners` must actually unsub.
*/
import { initAudioListeners } from './initAudioListeners';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', async () => {
@@ -40,7 +41,7 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
orbitBulkGuard: vi.fn(async () => true),
}));
import { initAudioListeners, usePlayerStore } from './playerStore';
import { usePlayerStore } from './playerStore';
import {
emitTauriEvent,
onInvoke,
+3 -5
View File
@@ -8,6 +8,8 @@
* Mocks `savePlayQueue` at the module boundary so we can assert the exact
* args passed to the Subsonic API call.
*/
import { initAudioListeners } from './initAudioListeners';
import { flushPlayQueuePosition } from './queueSync';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Explicit (non-spread) mock map — the `...actual` spread pattern lets the
@@ -40,11 +42,7 @@ vi.mock('@/api/lastfm', () => ({
}));
import { savePlayQueue } from '@/api/subsonic';
import {
flushPlayQueuePosition,
initAudioListeners,
usePlayerStore,
} from './playerStore';
import { usePlayerStore } from './playerStore';
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks } from '@/test/helpers/factories';
+3 -7
View File
@@ -10,6 +10,8 @@
* Drive emits via the `audio:progress` Tauri event (the only public path
* to `emitPlaybackProgress`).
*/
import { initAudioListeners } from './initAudioListeners';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress, type PlaybackProgressSnapshot } from './playbackProgress';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', async () => {
@@ -36,13 +38,7 @@ vi.mock('@/api/lastfm', () => ({
lastfmGetAllLovedTracks: vi.fn(async () => []),
}));
import {
getPlaybackProgressSnapshot,
initAudioListeners,
subscribePlaybackProgress,
usePlayerStore,
type PlaybackProgressSnapshot,
} from './playerStore';
import { usePlayerStore } from './playerStore';
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack } from '@/test/helpers/factories';
+2 -71
View File
@@ -1,62 +1,8 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { resolveReplayGainDb } from '../utils/resolveReplayGainDb';
import { shuffleArray } from '../utils/shuffleArray';
import { songToTrack } from '../utils/songToTrack';
import {
emitPlaybackProgress,
getPlaybackProgressSnapshot,
subscribePlaybackProgress,
type PlaybackProgressSnapshot,
} from './playbackProgress';
import { flushPlayQueuePosition } from './queueSync';
import { initAudioListeners } from './initAudioListeners';
import { installQueueUndoHotkey } from './queueUndoHotkey';
import { emitPlaybackProgress } from './playbackProgress';
import type { PlayerState } from './playerStoreTypes';
import { readInitialQueueVisibility } from './queueVisibilityStorage';
// Re-export so MainApp + the 3 playerStore characterization tests keep
// their existing `from './playerStore'` imports.
export { initAudioListeners };
// Re-export so bootstrap.ts + bootstrap.test keep their existing
// `from './playerStore'` imports.
export { installQueueUndoHotkey };
// Re-export so TauriEventBridge + persistence test keep their existing
// `from './playerStore'` imports.
export { flushPlayQueuePosition };
// Re-export the playback-progress public surface so existing call sites
// (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView,
// TauriEventBridge, plus the progress characterization test) keep their
// `from './playerStore'` imports working.
export {
getPlaybackProgressSnapshot,
subscribePlaybackProgress,
type PlaybackProgressSnapshot,
};
import {
_resetQueueUndoStacksForTest,
consumePendingQueueListScrollTop,
pushQueueUndoFromGetter,
registerQueueListScrollTopReader,
} from './queueUndo';
// Re-export for backward compatibility with the ~30 call sites that still
// import these helpers from playerStore. Phase E (store splits) will migrate
// the imports to '../utils/*' directly and drop these re-exports.
export { resolveReplayGainDb, shuffleArray, songToTrack };
// Re-export the queue-undo public API so existing callers (QueuePanel,
// test/helpers/storeReset) keep their `from './playerStore'` imports.
export {
_resetQueueUndoStacksForTest,
consumePendingQueueListScrollTop,
registerQueueListScrollTopReader,
};
import type { PlayerState, Track } from './playerStoreTypes';
export type { PlayerState, Track };
import { createLastfmActions } from './lastfmActions';
import { createMiscActions } from './miscActions';
import { runNext } from './nextAction';
@@ -70,12 +16,6 @@ import { createTransportLightActions } from './transportLightActions';
import { createUiStateActions } from './uiStateActions';
import { createUndoRedoActions } from './undoRedoActions';
// ─── Module-level playback primitives ─────────────────────────────────────────
// ─── Store ────────────────────────────────────────────────────────────────────
export const usePlayerStore = create<PlayerState>()(
persist(
(set, get) => {
@@ -125,20 +65,11 @@ export const usePlayerStore = create<PlayerState>()(
...createMiscActions(set, get),
...createScheduleActions(set, get),
// ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue, manual = true, _orbitConfirmed = false, targetQueueIndex) =>
runPlayTrack(set, get, track, queue, manual, _orbitConfirmed, targetQueueIndex),
// ── resume ───────────────────────────────────────────────────────────────
resume: () => runResume(set, get),
// ── next ────────────────────────────────────────────────────────────────
next: (manual = true) => runNext(set, get, manual),
// ── seek ─────────────────────────────────────────────────────────────────
seek: (progress) => runSeek(set, get, progress),
updateReplayGainForCurrentTrack: () => runUpdateReplayGainForCurrentTrack(set, get),
};
},
+1 -2
View File
@@ -4,9 +4,8 @@
* suffix fallback, the null-result skip, and the swallow-on-error
* contract.
*/
import type { Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from './playerStore';
const { invokeMock, setEntryMock, buildStreamUrlMock } = vi.hoisted(() => ({
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as { path: string; size: number } | null),
setEntryMock: vi.fn(),
+1 -2
View File
@@ -1,8 +1,7 @@
import type { Track } from './playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { buildStreamUrl } from '../api/subsonic';
import { useHotCacheStore } from './hotCacheStore';
import type { Track } from './playerStore';
/**
* Promote a track whose stream cache is full to the on-disk hot cache.
* Rust copies the cached bytes into the hot-cache directory and returns
+2 -6
View File
@@ -24,12 +24,8 @@ type SetState = (
type GetState = () => PlayerState;
/**
* Queue-mutation actions factored out of the playerStore `create()` body.
* All eleven members of the cluster: insertion (`enqueue`, `enqueueAt`,
* `playNext`, `enqueueRadio`, `setRadioArtistId`), pruning (`clearQueue`,
* `pruneUpcomingToCurrent`, `removeTrack`), and rearrangement
* (`reorderQueue`, `shuffleQueue`, `shuffleUpcomingQueue`). All but
* `setRadioArtistId` push a queue-undo snapshot and call
* Eleven queue-mutation actions. Shared invariant: every action except
* `setRadioArtistId` pushes a queue-undo snapshot and calls
* `syncQueueToServer` so the Navidrome `savePlayQueue` stays in sync.
*/
export function createQueueMutationActions(set: SetState, get: GetState): Pick<
+1 -2
View File
@@ -5,9 +5,8 @@
* in for `savePlayQueue`, the playerStore, and the playback-progress
* snapshot.
*/
import type { Track } from './playerStoreTypes';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from './playerStore';
const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number) => undefined),
playerState: {
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Track } from './playerStoreTypes';
import { savePlayQueue } from '../api/subsonic';
import { getPlaybackProgressSnapshot } from './playbackProgress';
import { usePlayerStore, type Track } from './playerStore';
import { usePlayerStore } from './playerStore';
/**
* Server-side play-queue persistence. Subsonic's `savePlayQueue` accepts
* the current queue, the active track id, and the position in ms so the
+1 -1
View File
@@ -4,8 +4,8 @@
* push, and (c) the scroll-top reader / consumer pair that QueuePanel uses
* to restore list scroll position after an undo/redo commit.
*/
import type { PlayerState, Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it } from 'vitest';
import type { PlayerState, Track } from './playerStore';
import {
QUEUE_UNDO_MAX,
_resetQueueUndoStacksForTest,
+1 -2
View File
@@ -1,5 +1,4 @@
import type { PlayerState, Track } from './playerStore';
import type { PlayerState, Track } from './playerStoreTypes';
/** Hard cap on undo/redo depth — keeps memory bounded for very long sessions. */
export const QUEUE_UNDO_MAX = 32;
+1 -2
View File
@@ -4,9 +4,8 @@
* atSeconds > 0.05, the wantPlaying=false branch that issues audio_pause,
* and the generation-mismatch bail-out.
*/
import type { Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from './playerStore';
const hoisted = vi.hoisted(() => {
const auth = {
activeServerId: 'srv',
+2 -2
View File
@@ -1,3 +1,4 @@
import type { Track } from './playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
@@ -7,8 +8,7 @@ import { getPlayGeneration, setIsAudioPaused } from './engineState';
import { touchHotCacheOnPlayback } from './hotCacheTouch';
import { isReplayGainActive, loudnessGainDbForEngineBind } from './loudnessGainCache';
import { playbackSourceHintForResolvedUrl, recordEnginePlayUrl } from './playbackUrlRouting';
import { usePlayerStore, type Track } from './playerStore';
import { usePlayerStore } from './playerStore';
/**
* Reload the Rust audio engine to match a queue-undo snapshot. Zustand
* alone can rewrite the queue + currentTrack, but the engine is still
+1 -2
View File
@@ -5,9 +5,8 @@
* the player-store state surface so the test can drive every input
* independently.
*/
import type { Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from './playerStore';
const { setRatingMock, recordSkipStarMock, playerSetStateMock, playerStateGet } = vi.hoisted(() => {
const playerState = {
queue: [] as Track[],
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Track } from './playerStoreTypes';
import { setRating } from '../api/subsonic';
import { useAuthStore } from './authStore';
import { usePlayerStore, type Track } from './playerStore';
import { usePlayerStore } from './playerStore';
/**
* Skip 1 behaviour: every user-initiated `next()` on an unrated track
* counts in `authStore.skipStarManualSkipCountsByKey` (persisted). Once the
+6 -12
View File
@@ -15,18 +15,12 @@ type SetState = (
type GetState = () => PlayerState;
/**
* Light transport actions factored out of the playerStore `create()`
* body everything except `resume` (~165 LOC, separate PR) and the
* scheduled pause/resume timer setters.
*
* - `stop` full reset: stops audio/radio, clears timers + seek + visual
* state, blanks playback metadata.
* - `pause` pauses audio (or radio), flushes queue position so other
* devices can pick up the resume point.
* - `resetAudioPause` flips the engine-paused flag without touching
* the UI `isPlaying` state. Used by `audio:ended` paths.
* - `togglePlay` guarded toggle so a double media-key tap can't race
* pause + resume into a stuck state.
* Light transport actions everything except `resume` (own module,
* see `resumeAction.ts`) and scheduled timers (`scheduleActions.ts`).
* `togglePlay` is guarded so a double media-key tap can't race
* pause + resume into a stuck state. `resetAudioPause` flips the
* engine-paused flag without touching the UI `isPlaying`, used by
* `audio:ended` paths.
*/
export function createTransportLightActions(set: SetState, get: GetState): Pick<
PlayerState,
+2 -7
View File
@@ -8,13 +8,8 @@ type SetState = (
) => void;
/**
* Pure-UI state setters that mutate playerStore fields with no audio
* engine / network side effects: starred + rating optimistic overrides,
* context menu modal, song info modal, queue panel visibility (with
* localStorage round-trip), fullscreen toggle, repeat mode cycle.
*
* Factored out of the playerStore `create()` body so the action set
* stays a flat object literal instead of a giant inline block.
* Pure-UI state setters: no audio engine / network side effects.
* Add new actions here only if they fit that contract.
*/
export function createUiStateActions(set: SetState): Pick<
PlayerState,