Four more domain-eng modules out of `api/subsonic.ts`:
- `subsonicPlaylists.ts` — `getPlaylists`/`getPlaylist`/`createPlaylist`
/`updatePlaylist`/`updatePlaylistMeta`/`uploadPlaylistCoverArt`/
`uploadArtistImage`/`deletePlaylist`. The two upload helpers use
the Tauri-side CORS bypass (`upload_playlist_cover` /
`upload_artist_image`).
- `subsonicPlayQueue.ts` — `getPlayQueue`/`savePlayQueue`.
- `subsonicRadio.ts` — Internet Radio CRUD (4 fns), Tauri-side cover
art ops (3 fns), RadioBrowser search/top + `fetchUrlBytes`.
- `subsonicStatistics.ts` — `fetchStatisticsLibraryAggregates`/
`fetchStatisticsOverview`/`fetchStatisticsFormatSample` with their
three per-server-folder caches and the `statisticsPageCacheKey`
helper. `STATS_CACHE_TTL` renamed from the misleadingly-shared
`RATING_CACHE_TTL` constant.
Also: drop ~20 now-unused type/value imports from `subsonic.ts`, trim
three orphan jsdoc comments left behind by earlier slices, fix four
`vi.mock` targets (`queueSync`, `playerStore.persistence`) plus the
dynamic `await import('../api/subsonic')` calls in `ContextMenu.tsx`
to point at the new module paths.
Pure code-move. subsonic.ts: 561 → 144 LOC (−417). What's left:
ping/pingWithCredentials/probeInstantMix/scheduleInstantMixProbe +
internal `apiWithCredentials`/`restBaseFromUrl`.
Seven domain-eng splits peel ~200 LOC of read endpoints out of
`api/subsonic.ts`:
- `subsonicStreamUrl.ts` — `buildStreamUrl`, `coverArtCacheKey`,
`buildCoverArtUrl`, `buildDownloadUrl` (token-signed URL builders
for the four /rest endpoints we hand to the browser).
- `subsonicStarRating.ts` — `getStarred`, `star`, `unstar`,
`setRating`, `probeEntityRatingSupport`. `setRating` still triggers
the lazy `navidromeBrowse` cache invalidation; the same-folder
lazy import path is preserved.
- `subsonicSearch.ts` — `search`, `searchSongsPaged`.
- `subsonicScrobble.ts` — `scrobbleSong`, `reportNowPlaying`,
`getNowPlaying`.
- `subsonicAlbumInfo.ts` — `getAlbumInfo2`.
- `subsonicLyrics.ts` — `getLyricsBySongId`.
- `subsonicGenres.ts` — `getGenres`, `getAlbumsByGenre`.
63 external call sites migrated to direct imports. Four `vi.mock`
targets in the store-level tests pointed at `../api/subsonic` and
were updated to the new module paths.
Pure code-move. subsonic.ts: 762 → 561 LOC (−201).
Three domain-eng modules peel ~316 LOC of fetch/mapping out of
`api/subsonic.ts`:
- `subsonicLibrary.ts` — browse + random + per-song fetch
(`getMusicDirectory`, `getMusicIndexes`, `getMusicFolders`,
`getRandomAlbums`, `getAlbumList`, `getRandomSongs`,
`getRandomSongsFiltered`, `getSong`, `getAlbum`,
`filterSongsToActiveLibrary`, `similarSongsRequestCount`, plus
the private `albumIdsInActiveLibraryScope` cache).
- `subsonicArtists.ts` — artist endpoints (`getArtists`, `getArtist`,
`getArtistInfo`, `getTopSongs`, `getSimilarSongs2`,
`getSimilarSongs`). Uses Library's `filterSongsToActiveLibrary` and
`similarSongsRequestCount` for the per-library scoping fallback.
- `subsonicRatings.ts` — `parseSubsonicEntityStarRating` parser plus
`prefetchArtistUserRatings` and `prefetchAlbumUserRatings` workers
with the shared 7-min cache. Calls back into Library/Artists for
the per-id fetch.
51 external call sites migrated to direct imports. No re-export
shims in `subsonic.ts`. Statistics endpoints still in `subsonic.ts`
keep their `RATING_CACHE_TTL` constant locally (same 7-min window).
subsonic.ts: 1078 → 762 LOC (−316).
First Phase F slice. Splits the 1333-LOC `api/subsonic.ts` along its
two most obvious axes:
- `subsonicTypes.ts` — all ~24 exported interfaces + type aliases
(album/song/artist/playlist/directory/genre/now-playing/radio,
random-songs filters, three statistics shapes, search + starred
results, AlbumInfo, structured-lyrics types, etc.) plus the
`RADIO_PAGE_SIZE` constant.
- `subsonicClient.ts` — token-auth + `getClient` + `api<T>()` +
`libraryFilterParams` + `secureRandomSalt` / `getAuthParams` /
`SUBSONIC_CLIENT`. The credential-bearing API helpers
(`pingWithCredentials`, `apiWithCredentials`, `restBaseFromUrl`,
`probeInstantMixWithCredentials`) stay in `subsonic.ts` for now —
they could move into the client module in a follow-up.
66 external call sites migrated to direct imports from the new
modules (no re-export shims in `subsonic.ts`). Pure code-move;
contract test stays green.
subsonic.ts: 1333 → 1078 LOC (−255).
The persist middleware's `onRehydrateStorage` callback was ~100 LOC
of legacy-shape migrations: hot-cache/preload mutual-exclusion reset,
lyricsServerFirst+enableNeteaselyrics → lyricsSources one-time
migration, Linux smooth-scroll one-shot, seekbar `'waveform'` →
`'truewave'` rename, animationMode/reducedAnimations cruft strip,
loudnessPreIsRefV1 reference recalibration, enableAppleMusicCoversDiscord
→ discordCoverSource enum migration, plus the sanitizers for
mixMinRating / randomMixSize / skipStarManualSkipCountsByKey.
Moved into `computeAuthStoreRehydration(state) → Partial<AuthState>`
in `authStoreRehydrate.ts`; the store's callback is now a four-line
wrapper. Pure code-move — same migration logic, same call order.
authStore.ts: 270 → 158 LOC (−112). Total Phase-E-auth journey:
889 → 158 LOC (−82%). All non-trivial content is now out of the
authStore body; what remains is state-init, 13 factory spreads, 2
derived getters, persist config, and the thin onRehydrate wrapper.
Three factories for the actions that carry real logic (not just
`set({ field: v })` pass-through):
- `createSkipStarActions` — skip-to-1★ counter with threshold check
and per-`<activeServerId><trackId>` storage key. Disabling the
feature wipes the counter map so a re-enable doesn't resume from
stale partial counts. Crossing the threshold deletes the key so
the next session starts fresh.
- `createMusicLibraryActions` — `setMusicFolders` falls back to
`'all'` when the persisted filter points at a folder the server
no longer reports; `setMusicLibraryFilter` bumps
`musicLibraryFilterVersion` so subscribed pages refetch.
- `createPerServerCapabilityActions` — `setEntityRatingSupport`,
`setAudiomuseNavidromeEnabled`, `setSubsonicServerIdentity`,
`setInstantMixProbe`, `setAudiomuseNavidromeIssue`. Each branch
knows which neighbouring per-server maps to wipe when the input
invalidates them (identity not AudioMuse-eligible / probe empty /
audiomuse disabled).
Pure code-move. authStore.ts: 384 → 270 LOC (−114). All trivial-setter
and logic-bearing action bodies are now out of the store body; only
the persist config + `onRehydrateStorage` migration block remains as
a non-factory chunk (target for E.47).
Three action-factories peel ~25 setters out of the authStore body,
following the playerStore action-factory pattern from Phase E:
- `createServerProfileActions` — `addServer`, `updateServer`,
`removeServer` (the non-trivial one — drops every per-server map
entry for the removed id), `setServers`, `setActiveServer`,
`setLoggedIn`, `setConnecting`, `setConnectionError`, `logout`.
- `createAuthLastfmActions` — credentials + session connect/disconnect
+ error flag + master scrobbling toggle. Network calls (love /
scrobble) stay in the playerStore-side `lastfmActions.ts`.
- `createAudioSettingsActions` — replay-gain / normalization /
loudness mode toggles (each calls `usePlayerStore.getState()
.updateReplayGainForCurrentTrack()` so a running track catches up),
plus crossfade / gapless / hi-res / audio-output (no engine
callback needed).
Pure code-move; no behaviour change. authStore.ts: 518 → 428 LOC (−90).
First slice of the authStore split. Pure code-move: no behaviour change.
- `authStoreTypes.ts` — `ServerProfile`, `AuthState`, plus all union
types (`SeekbarStyle`, `LoggingMode`, `NormalizationEngine`,
`DiscordCoverSource`, `LoudnessLufsPreset`, `LyricsSourceId`,
`LyricsSourceConfig`, `TrackPreviewLocation`, `TrackPreviewLocations`).
- `authStoreDefaults.ts` — `LOUDNESS_LUFS_PRESETS`,
`DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB`,
`TRACK_PREVIEW_LOCATIONS`, `DEFAULT_TRACK_PREVIEW_LOCATIONS`,
`DEFAULT_LYRICS_SOURCES`, `MIX_MIN_RATING_FILTER_MAX_STARS`,
`RANDOM_MIX_SIZE_OPTIONS`.
- `authStoreHelpers.ts` — `generateId`,
`sanitizeLoudnessLufsPreset`, `sanitizeLoudnessPreAnalysisFromStorage`,
`clampMixFilterMinStars`, `clampRandomMixSize`,
`clampSkipStarThreshold`, `skipStarCountStorageKey`,
`sanitizeSkipStarCounts`.
12 external call sites migrated to direct imports from the new
modules (no re-export shims left in authStore.ts — applies the
[feedback_prevent_god_modules] rule 5: avoid re-export debt).
authStore.ts: 889 → 518 LOC (−371).
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.
Move the ~280-LOC `playTrack` action body into `runPlayTrack(set, get,
track, queue, manual, _orbitConfirmed, targetQueueIndex)` in
`src/store/playTrackAction.ts`, following the helper-with-thin-wrapper
pattern from E.37–E.40.
Covers all three guard layers (Orbit bulk-gate, Orbit-host
single-track protection, ghost-command 500 ms guard), the
same-track-hot-promote branch, and the inner `runPlayTrackBody`
closure that resolves the URL, updates store + normalization
optimistically, invokes the Rust engine, and on success seeks to
the visual target if there was a pending one.
With playTrack extracted, every action body has been moved out of
the playerStore create() body. playerStore.ts: 515 → 180 LOC (−335),
down from Phase E's starting 3732 LOC (~95% smaller).
Move the ~180-LOC `next` action body into `runNext(set, get, manual)`
in `src/store/nextAction.ts`, following the helper-with-thin-wrapper
pattern from E.37–E.39.
Covers all three top-level outcomes:
- Has next slot → playTrack + proactive infinite-queue / radio top-up
when ≤ 2 of each remain ahead. Both top-ups skipped inside an
Orbit session (host owns the queue).
- Queue exhausted, repeat=all → wrap to index 0.
- Queue exhausted, repeat=off → stop, unless radio-flagged or
infinite queue is enabled (each fetches a fresh batch + continues),
or an Orbit session is active (stop locally, let useOrbitGuest sync).
Pure code-move. playerStore.ts: 702 → 515 LOC (−187).
Move the ~165-LOC `resume` action body into `runResume(set, get)`
in `src/store/resumeAction.ts`, following the helper-with-thin-wrapper
pattern from E.37 + E.38.
Covers all three resume branches:
- Orbit guest catch-up to host's live position (same-track → seek;
different-track → playTrack + deferred seek).
- Radio resume via HTML5 audio.
- Regular track resume: warm engine (audio_resume) or cold start
(hot-cache promote → getSong refetch → audio_play → seek to
persisted currentTime, with currentTrack fallback on fetch fail).
Pure code-move. playerStore.ts: 868 → 702 LOC (−166).
Move the ~63-LOC `seek` action body into `runSeek(set, get, progress)`
in `src/store/seekAction.ts`, following the helper-with-thin-wrapper
pattern from E.37 (updateReplayGain).
The action handles the full seek path: 0..1 fraction → bounded time,
100 ms debounce, hot-cache-rebind branch via playTrack, and the
recoverable-seek-error retry burst (visual target pin + restart on
"not seekable" + bounded retry window).
Pure code-move. playerStore.ts: 938 → 868 LOC (−70).
Move the ~50-LOC `updateReplayGainForCurrentTrack` action body into
`runUpdateReplayGainForCurrentTrack(set, get)` in a dedicated module,
following the helper-with-thin-wrapper pattern from
`applyQueueHistorySnapshot` (E.30) rather than a single-action
factory.
The action recomputes the normalization snapshot + pushes fresh
ReplayGain/loudness state to the engine when mode toggles change
or the loudness cache fills mid-playback.
Pure code-move. playerStore.ts: 991 → 938 LOC (−53).
Four scheduled-timer actions extracted into `createScheduleActions`
(`src/store/scheduleActions.ts`):
- `schedulePauseIn` / `scheduleResumeIn` — clamp the delay to ≥ 500 ms,
store absolute target + start timestamps (for the countdown UI),
and arm a single-shot timer.
- `clearScheduledPause` / `clearScheduledResume` — cancel the timer
and blank the timestamps.
Pure code-move. playerStore.ts: 1030 → 991 LOC (−39, first sub-1000
state since Phase E started).
Heterogeneous "misc" cluster — seven small-to-medium actions that
didn't fit the more focused factories (transport / queue / Last.fm /
UI state):
- `playRadio` — switches the player into HTML5 radio mode.
- `previous` — Subsonic-style back: restart if past 3 s, otherwise
jump to the previous queue index.
- `setVolume` — clamps + propagates to Rust engine and radio sink.
- `setProgress` — pure UI state update for progress polling.
- `initializeFromServerQueue` — startup queue restore from Navidrome.
- `reanalyzeLoudnessForTrack` — toast + reseed the loudness cache.
- `reseedQueueForInstantMix` — replace the queue with a single track.
Pure code-move. playerStore.ts: 1167 → 1030 LOC (−137).
Two small action-factory extractions in one PR, both following the
pattern from E.31–E.33.
- `createTransportLightActions` — `stop`, `pause`, `resetAudioPause`,
`togglePlay`. Everything in the pause/togglePlay cluster except
`resume` (~165 LOC, deferred to its own PR).
- `createUndoRedoActions` — `undoLastQueueEdit`, `redoLastQueueEdit`.
Trivial wrappers around `applyQueueHistorySnapshot` + the queue-undo
stack helpers.
Pure code-move. playerStore.ts: 1247 → 1167 LOC (−80).
Move all eleven queue-mutation actions (enqueue, enqueueAt, playNext,
enqueueRadio, setRadioArtistId, pruneUpcomingToCurrent, clearQueue,
reorderQueue, shuffleQueue, shuffleUpcomingQueue, removeTrack) from
the playerStore create() body into a new `createQueueMutationActions`
factory, following the action-factory pattern established in E.31
(Last.fm) + E.32 (UI state).
Pure code-move: existing characterization tests in
playerStore.queue.test.ts continue to exercise the actions through
the full store flow.
playerStore.ts: 1463 → 1247 LOC (−216).
Ten pure-UI state setters move into `src/store/uiStateActions.ts` as a
`createUiStateActions(set)` factory:
- `setStarredOverride`, `setUserRatingOverride` — optimistic overrides
- `openContextMenu`, `closeContextMenu` — context menu modal
- `openSongInfo`, `closeSongInfo` — song info modal
- `toggleQueue`, `setQueueVisible` — queue panel toggle with persisted
localStorage round-trip
- `toggleFullscreen` — fullscreen player toggle
- `toggleRepeat` — repeat mode cycle
All ten are pure state mutators (no audio engine / network calls).
Store body uses `...createUiStateActions(set)`. Action-factory pattern
established by E.31 reused here. `persistQueueVisibility` import drops
from playerStore.
playerStore 1504 → 1463 LOC.
Four Last.fm-related actions move out of the playerStore create()
body into `src/store/lastfmActions.ts` as a `createLastfmActions(set, get)`
factory:
- `toggleLastfmLove` — flip love + write through to the cache map
- `setLastfmLoved` — force-set (used by external events)
- `setLastfmLovedForSong` — write cache for an arbitrary title/artist
- `syncLastfmLovedTracks` — startup bulk fetch + merge
The store body uses `...createLastfmActions(set, get)` to inline them
back into the actions map. First action-factory cut — sets the
pattern for follow-up Phase E extractions of larger action groups
(playback transport, queue mutators, etc.).
Side-cleanup: 3 last.fm imports drop from playerStore
(`lastfmLoveTrack`, `lastfmUnloveTrack`, `lastfmGetAllLovedTracks`)
since they only fed the moved actions.
playerStore 1550 → 1504 LOC.
The ~150-LOC `applyQueueHistorySnapshot` helper that lived inside the
playerStore `create((set, get) => { … })` closure moves out into
`src/store/applyQueueHistorySnapshot.ts`. The function now takes the
zustand `set` / `get` references as explicit parameters; the two
caller sites (`undoLastQueueEdit`, `redoLastQueueEdit`) pass them
through unchanged.
Side-cleanup: four imports that only fed the moved helper drop from
playerStore (getPlaybackSourceKind, queueUndoRestoreAudioEngine,
setPendingQueueListScrollTop, shallowCloneQueueTracks).
playerStore 1706 → 1550 LOC (−156).
The undo/redo flow keeps behaving 1:1 — the function is exported and
deterministic; the closure capture only existed for the set/get pair
which is trivial to pass through.
The two TypeScript type definitions that other store modules already
depend on move into `src/store/playerStoreTypes.ts`. playerStore.ts
re-exports both for backward compatibility — the ~40 callers
(components, tests, sibling store modules) keep their existing
`import type { Track } from '@/store/playerStore'` imports working.
Side-cleanup: `InternetRadioStation` and `PlaybackSourceKind` imports
drop from playerStore (the new types module pulls them directly).
No behaviour change — pure type relocation.
playerStore 1879 → 1706 LOC (−173).
Two small file-private bits move out of playerStore.ts:
- `src/store/queueVisibilityStorage.ts` — `readInitialQueueVisibility`
and `persistQueueVisibility` (the QueuePanel show/hide toggle
survives reloads via a localStorage round-trip; SSR / private-mode
failures swallowed silently).
- `src/store/queueUndoHotkey.ts` — `installQueueUndoHotkey` (Ctrl+Z /
Cmd+Z queue undo, Ctrl+Shift+Z redo, document-capture; skips text
fields so native text undo stays; idempotent via window-scoped
flag; mini-player window skipped). Added a `_resetQueueUndoHotkeyForTest`
that removes the keydown listener too (needed so vitest specs
don't accumulate handlers across tests).
playerStore re-exports `installQueueUndoHotkey` so bootstrap + tests
keep their existing `from './playerStore'` imports. The
queue-visibility helpers were file-private; no re-exports.
Side-cleanup: `getWindowKind` import is gone from playerStore (only
the moved hotkey installer used it).
16 focused tests pin the storage round-trip, the idempotency flag, the
modifier + key matching, the editable-target skip, and the
preventDefault contract.
playerStore 1940 → 1879 LOC.
`initAudioListeners` (~430 LOC) moves out of playerStore.ts into
`src/store/initAudioListeners.ts`. The function brings together
several distinct orchestrators: the 7 audio event listeners, startup
Last.fm loved-sync, initial audio-settings push to Rust, the auth
subscriber for live audio-settings changes, the analysis-storage
change handler, MPRIS / OS media controls sync, radio ICY metadata
forward, and Discord Rich Presence sync. Pure code move — no
sub-orchestrator split yet (could be follow-up cuts under
src/store/audio-init/ if it gets unwieldy).
playerStore re-exports `initAudioListeners` so MainApp + the three
characterization test files keep their `from './playerStore'`
imports.
Side-cleanup: 15 more imports drop from playerStore that were only
fed into the now-moved code (analysisSync, normalizationDebug,
normalizationIpcDedupe, normalizationCompare, NORMALIZATION_UI_*,
listen, streamUrlTrackId, buildCoverArtUrl, getAlbumInfo2,
normalizeAnalysisTrackId, bumpWaveformRefreshGen,
clearLoudnessCacheStateForTrackId, setCachedLoudnessGain).
playerStore 2394 → 1940 LOC (−454).
The five `handleAudio*` functions + the `NormalizationStatePayload`
type move into `src/store/audioEventHandlers.ts`:
- `handleAudioPlaying` — flips isPlaying true + resets progress emit
throttles + lets hot-cache prefetch resume
- `handleAudioProgress` (~220 LOC) — the big one: seek-guard, visual
target apply, live progress emit, store commit, scrobble@50%,
server heartbeat, byte-preload + gapless-chain triggers
- `handleAudioEnded` — gapless-switch suppression + radio cleanup +
repeat-one branch with hot-cache promote + queue advance
- `handleAudioTrackSwitched` — gapless auto-advance UI sync + Now
Playing + waveform/loudness refresh
- `handleAudioError` — toast + skip-after-1.5 s with generation
guard
`initAudioListeners` (still in playerStore) imports the handlers from
the new module. Side-cleanup: 24 imports in playerStore that were
only consumed by these handlers are gone (bumpPerfCounter,
getPerfProbeFlags, the throttle accessor pair, the playback-progress
emit pair, scrobbleSong, lastfmScrobble, seek target / debounce
accessors, gapless-preload accessors, plus a handful of constants).
Behaviour preserved verbatim — existing `playerStore.events.test.ts`
+ `playerStore.progress.test.ts` characterization suites still pin
the handlers via the live Tauri event channel.
playerStore 2779 → 2394 LOC (−385).
Two thematic cuts in one PR:
- `src/store/queueUndoAudioRestore.ts` — `queueUndoRestoreAudioEngine`
(~70 LOC). Reload the Rust audio engine to match a queue-undo
snapshot: audio_play with snapshot track params, optional
audio_seek to the snapshot position, audio_pause if the snapshot
captured a paused state. Generation-guard bails on concurrent
playTrack. Drives recordEnginePlayUrl, setDeferHotCachePrefetch,
and touchHotCacheOnPlayback.
- `src/store/loudnessPrefetch.ts` — `prefetchLoudnessForEnqueuedTracks`.
Warms the loudness cache for the current track + next-N window
after a bulk enqueue, no-op when normalization isn't loudness.
Both file-private; no caller-side changes outside playerStore's own
imports. Removes the unused `collectLoudnessBackfillWindowTrackIds`
import that was only feeding the moved prefetch helper.
11 tests across the two modules pin the orchestration: audio_play
payload + seek-when-near-zero + wantPlaying=false → audio_pause +
generation-mismatch bail + .finally clears the hot-cache gate even
on errors; engine guard + window forwarding + sync-flag for prefetch.
playerStore 2865 → 2779 LOC.
Cluster of four small mutables in two modules:
- `src/store/radioSessionState.ts` — `radioFetching` (concurrent
fetch guard) + `currentRadioArtistId` (seed artist that survives
track advances) + `radioSessionSeenIds` (dedupe set including
HISTORY_KEEP-evicted entries, fixes issue #500).
- `src/store/infiniteQueueState.ts` — `infiniteQueueFetching`
concurrent fetch guard.
Sed-driven bulk rewrite for the >35 direct-access sites. The four
`= new Set()` resets become `clearRadioSessionSeenIds()` calls and
the `setRadioArtistId` / `enqueueRadio` actions read through
`getCurrentRadioArtistId()` instead of touching the mutable directly.
15 focused tests across the two modules.
playerStore 2867 → 2865 LOC.
Two thematic cuts in one PR:
- `src/store/engineState.ts` — `isAudioPaused` (warm-vs-cold-resume
decision flag) + `playGeneration` (monotonically bumped guard
counter used by long-running async callbacks to detect concurrent
playTrack and bail). Get/set accessors + `bumpPlayGeneration()`
that returns the new value (mirrors the original `++playGeneration`
pattern).
- `src/store/seekDebounce.ts` — seek-slider debounce timer behind
`armSeekDebounce(delayMs, onFire)` / `clearSeekDebounce()` /
`isSeekDebouncePending()`. Collapses the recurring inline
`if (seekDebounce) { clearTimeout(...); seekDebounce = null; }`
triple into single calls.
Sed-driven bulk rewrite for the >60 direct-access sites preserved
semantics verbatim (one JSDoc reference to `isAudioPaused` kept as
comment text). 14 tests across the two modules.
playerStore 2869 → 2867 LOC net (small delta — the imports are bigger
than the savings, but the mutables are no longer reachable from
outside their owning modules).
The streaming-fallback seek recovery loop + its visual coverup move
into `src/store/seekFallbackState.ts`:
- 6 module mutables (retry timer + start + target; fallback trackId +
restartAt; visual target)
- 3 const gates (180 ms retry interval, 6 s retry budget, 1.6 s
visual guard)
- `scheduleSeekFallbackRetry(trackId, seconds)` — bounded retry loop
that hits `audio_seek` every 180 ms up to 6 s, re-scheduling on
recoverable errors and clearing the visual target on success or
hard failure
- `clearSeekFallbackRetry()` — cancel timer + reset state
- get/set accessors for the three caller-touched fields
(`SeekFallbackVisualTarget`, trackId, restartAt)
playerStore's ~30 direct-access sites become accessor calls. Inside
the progress handler the visual target is captured into a local once
per call so type narrowing + repeated reads stay clean.
17 focused tests pin the constants, get/set round-trips, the retry
loop's happy path (setSeekTarget + clear visual), recoverable retry
re-schedule, non-recoverable abort, track-change abort, retry-budget
exhaustion, and the three coalesce cases (different track id /
seconds delta > 0.25 s / seconds delta ≤ 0.25 s).
playerStore 2909 → 2869 LOC.
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.
The HTMLAudioElement that handles internet-radio streams + its six
event listeners + the bounded stalled-reconnect loop move into
`src/store/radioPlayer.ts`. The module owns the singleton audio
element, the `radioStopping` suppression flag, the reconnect counter
and timer, and `MAX_RADIO_RECONNECTS`. Public API:
- `playRadioStream(url, volume)` — sets src + clamped volume + play,
resets reconnect counter
- `pauseRadio()` / `resumeRadio()` — soft pause/resume (keep src)
- `stopRadio()` — full stop (flag + pause + clear src + cancel
reconnect timer)
- `setRadioVolume(v)` — direct volume with clamp
- `clearRadioReconnectTimer()` — exposed for cleanup paths
playerStore's seven direct-access patterns become API calls. The
three `radioStopping = true; pause; src = ''` triples collapse to
single `stopRadio()` calls.
18 tests pin the imperative API + the listener loop: ended/error
state-clear, stalled → 4 s reconnect, stalled coalescing, the
MAX_RADIO_RECONNECTS hard stop, playing-resets-counter, and the
suspend → cancel.
playerStore 2983 → 2909 LOC.
Two thematic cuts in one PR:
- `src/store/waveformRefresh.ts` — `refreshWaveformForTrack` plus its
`WaveformCachePayload` type. Fetches the cached waveform row and
applies bins to the player store, guarded by both the refresh
generation snapshot and the current-track check so a stale read
can't overwrite fresh data.
- `src/store/loudnessRefresh.ts` — `refreshLoudnessForTrack` plus the
`loudnessRefreshInflight` coalescing map and `LoudnessCachePayload`
type. Orchestrates the loudness fetch: dedupe concurrent calls by
(trackId, syncEngine, target), distinguish hit vs miss, enqueue
bounded backfill, suppress stale-target results by recursive retry.
Both file-private; no caller-side changes outside playerStore's own
imports. Imports of helpers that were only used by these two functions
get cleaned up out of playerStore (coerceWaveformBins, getBackfillAttempts,
forgetLoudnessGain, redactSubsonicUrlForLog, LOUDNESS_BACKFILL_WINDOW_AHEAD,
isTrackInsideLoudnessBackfillWindow, etc.).
20 tests across the two modules pin the orchestration: gen + current-track
guards on waveform; coalesce + hit/miss/backfill/stale-target/sync-flag
branches on loudness.
playerStore 3139 → 2983 LOC — first sub-3000 milestone for Phase E.
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.
`promoteCompletedStreamToHotCache` — wraps the `promote_stream_cache_to_hot_cache`
Rust IPC, forwards the resolved path + size into `useHotCacheStore` as a
`'stream-promote'` entry — moves into `src/store/promoteStreamCache.ts`.
File-private with four call sites; no caller-side changes outside
playerStore's own import.
9 focused tests pin the payload shape (incl. the `'mp3'` suffix fallback
and the null customDir pass-through), the success path that records the
entry, the early-returns for null / empty path, the `size || 0` fallback,
and the silent error swallow.
playerStore 3207 → 3189 LOC.
Three coordinating mutables move into `src/store/gaplessPreloadState.ts`
behind a thin API:
- `gaplessPreloadingId` / `bytePreloadingId` — guards so the runtime
doesn't fire a chain-/byte-preload twice for the same id
- `lastGaplessSwitchTime` — timestamp used by the 500–600 ms
ghost-IPC suppression guards in `handleAudioEnded` and `playTrack`
Public API: `get…` / `set…` accessors plus `clearPreloadingIds`
(atomic clear of both guards — collapses three `= null; = null;`
duplications) and `markGaplessSwitch` (`Date.now()` stamp). The
mutables are no longer reachable from outside the module.
13 focused tests pin the get/set round-trips, independence between
the two guards, the atomic clear, and the timestamp progression.
playerStore 3208 → 3207 LOC (small delta because the `= null; = null;`
inlines compress to single `clearPreloadingIds()` calls but the
module itself adds the accessor functions to the import list).
The server-side queue persistence (Subsonic `savePlayQueue`) helpers
move into `src/store/queueSync.ts`:
- `syncQueueToServer` — 5-second debounce for rapid edits
- `flushQueueSyncToServer` — immediate flush (heartbeat, pause,
app close)
- `flushPlayQueuePosition` — exported wrapper that reads the live
playerStore queue + playback-progress current-time, skips radio
sessions
- `getLastQueueHeartbeatAt` — accessor the 15-second heartbeat
throttle in `handleAudioProgress` reads
The two timer/heartbeat mutables (`syncTimeout`, `lastQueueHeartbeatAt`)
are no longer reachable from outside the module. `flushPlayQueuePosition`
is re-exported from playerStore so TauriEventBridge + the persistence
characterization test keep their existing imports.
13 focused tests pin the debounce window, the 1000-id queue cap, the
cancel-on-immediate-flush behaviour, the no-op guards (empty queue /
null currentTrack / radio session), and the heartbeat-timestamp
contract.
playerStore 3238 → 3208 LOC.
The `LOUDNESS_BACKFILL_WINDOW_AHEAD` constant, the predicate
`isTrackInsideLoudnessBackfillWindow`, and the id-list collector
`collectLoudnessBackfillWindowTrackIds` move into
`src/store/loudnessBackfillWindow.ts`.
`isTrackInsideLoudnessBackfillWindow` was rewritten to be pure — it
now takes the queue / queueIndex / currentTrack as explicit
parameters instead of reading `usePlayerStore.getState()` internally.
The one call site in `refreshLoudnessForTrack` reads state once and
passes it through. Net: zero behaviour change, clearer test surface
(no store mocks), no top-level coupling back to playerStore.
`prefetchLoudnessForEnqueuedTracks` stays in playerStore — it calls
`refreshLoudnessForTrack` which lives there too.
13 focused tests pin the window-slides-with-queueIndex contract, the
clamp at the end of the queue, the empty-input fallbacks, and the
duplicate collapse when currentTrack is also in the ahead range.
playerStore 3261 → 3238 LOC.
The two parallel maps that bound the per-track loudness backfill
retries (`analysisBackfillInFlightByTrackId`,
`analysisBackfillAttemptsByTrackId`) plus the `MAX_BACKFILL_ATTEMPTS_PER_TRACK`
constant and the `resetLoudnessBackfillStateForTrackId` reseed helper
move into `src/store/loudnessBackfillState.ts`. The new module exposes
a thin API:
- `isBackfillInFlight` / `getBackfillAttempts` (reads)
- `markBackfillInFlight(trackId, nextAttempt)` (atomic flag + counter)
- `clearBackfillInFlight` (after promise settles)
- `resetBackfillAttempts` (after refresh-hit)
- `resetLoudnessBackfillStateForTrackId` (full reset across both id forms)
playerStore's five direct-access sites inside `refreshLoudnessForTrack`
become API calls; the mutables are no longer reachable from outside the
module. 13 focused tests pin atomicity, independence between tracks,
the partial-clear shapes (flag-only / counter-only), and the two-form
reseed expansion.
playerStore 3263 → 3261 LOC (small line delta because the inflight
flag + counter setup collapses to one call but the reset helper is no
longer inline).
`applySkipStarOnManualNext` — the helper that records a manual `next()`
into the per-track skip counter and auto-rates the track 1★ once the
configured threshold crosses — moves into `src/store/skipStarRating.ts`.
File-private with one call site; no caller-side changes outside
playerStore's own import.
10 focused tests pin each early-return branch (manual=false, null
track, threshold not crossed, recordSkipStarManualAdvance returning
null, already-rated via override / queue / passed-track) plus the
happy path that calls setRating(1) + the state update for the queue,
currentTrack, and override map. Promise rejections are verified to
be swallowed.
playerStore 3291 → 3263 LOC.
Two small file-private helpers move into dedicated modules under
src/store/:
- `waveformRefreshGen.ts` — the per-track generation counter that
guards against applying a stale waveform read after the cache was
invalidated. Exposes `bumpWaveformRefreshGen` (existing) +
`getWaveformRefreshGen` (new accessor that replaces the two direct
reads at `refreshWaveformForTrack`).
- `hotCacheTouch.ts` — `touchHotCacheOnPlayback` with its empty-id
guards, called from every `audio_play` entry point.
Both file-private; no caller-side changes outside playerStore's own
imports. 8 focused tests pin the generation-increment + isolation
contract and the touch helper's empty-id guards.
playerStore 3302 → 3291 LOC.
`invokeAudioSetNormalizationDeduped` (450 ms window for
`audio_set_normalization`) and `invokeAudioUpdateReplayGainDeduped`
(250 ms window for `audio_update_replay_gain`, with LUFS-target /
pre-trim implicitly contributing to the dedupe key) move into
`src/store/normalizationIpcDedupe.ts` along with their four
"last-invoked" mutables.
File-private throughout; three internal call sites become plain
imports. 11 focused tests cover the window-boundary behaviour, the
payload-field sensitivity, the engine-mode key contribution
(re-fires when LUFS target changes even with identical gain), and
the null / NaN serialization.
playerStore 3369 → 3302 LOC.
Two timer mutables (`scheduledPauseTimer`, `scheduledResumeTimer`) plus
the three clear helpers move into `src/store/scheduleTimers.ts`. The
module also gains `schedulePauseTimer(delayMs, onFire)` /
`scheduleResumeTimer(delayMs, onFire)` so callers no longer need to do
the `window.setTimeout(...) as unknown as number` cast or null the
handle inside the fire callback — the module auto-clears its own
reference before invoking the user callback.
`schedulePauseIn` / `scheduleResumeIn` store actions are now four lines
shorter each and don't reach into the timer mutables.
playerStore 3389 → 3369 LOC. 9 focused tests cover schedule + fire +
clear + replace-on-reschedule + independence between the two timers.
The two parallel maps (`cachedLoudnessGainByTrackId`,
`stableLoudnessGainByTrackId`) plus their helpers
(`isReplayGainActive`, `loudnessCacheStateKeysForTrackId`,
`clearLoudnessCacheStateForTrackId`, `loudnessGainDbForEngineBind`)
move into `src/store/loudnessGainCache.ts`. The new module exposes a
thin API:
- `getCachedLoudnessGain` / `setCachedLoudnessGain`
- `hasStableLoudness` / `markLoudnessStable` (atomic set + stable)
- `forgetLoudnessGain` (single-key delete) vs
`clearLoudnessCacheStateForTrackId` (two-form delete)
- existing names kept for `isReplayGainActive`,
`loudnessGainDbForEngineBind`, `loudnessCacheStateKeysForTrackId`
playerStore's seven direct-access sites (delete pairs, set-pair, stable
flag check, cached read, neighbour-cache write) become API calls — the
mutables are no longer reachable from outside the module.
All helpers were file-private; no caller-side changes outside
playerStore's own imports. 22 focused tests pin the API surface including
the partial-vs-stable visibility split and the two delete-shape variants.
playerStore 3415 → 3389 LOC.
`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.
`deriveNormalizationSnapshot` — the loudness / replaygain / off branch
that the runtime uses to compute the normalization fields on every track
switch and queue rewrite — moves into src/store/normalizationSnapshot.ts.
File-private, four internal call sites updated to import from the new
module. Adds 8 focused tests pinning each branch (off, loudness with
target LUFS, replaygain enabled with tag + pre-gain, replaygain enabled
with fallback) plus neighbour-track context for album-mode resolution.
playerStore 3470 → 3434 LOC.
Three file-private helpers + their shared module-scoped track id move
into src/store/playbackUrlRouting.ts: recordEnginePlayUrl,
playbackSourceHintForResolvedUrl, shouldRebindPlaybackToHotCache. The
`lastOpenedWithHttpTrackId` mutable goes with them — only those three
read it. No external callers, no re-exports needed.
Adds 12 focused tests covering the source-kind classifier
(stream / hot / offline), the rebind decision across stream:-prefix
forms, the empty-serverId / un-recorded edge cases, and the
test-only reset helper.
playerStore 3490 → 3470 LOC.
The high-frequency PlaybackProgressSnapshot channel — type, mutable
snapshot, listener Set, emit/get/subscribe — moves into a dedicated
src/store/playbackProgress.ts. playerStore re-exports the public 3
(getPlaybackProgressSnapshot, subscribePlaybackProgress, the type) so
the 5+ external callers (PlayerBar, FullscreenPlayer, WaveformSeek,
LyricsPane, MobilePlayerView, TauriEventBridge) keep their existing
imports.
Direct unit tests pin the delta short-circuit (`currentTime <0.005`,
`progress/buffered <0.0002`) that keeps idle CPU bounded. The
end-to-end Tauri-event drive path remains covered by the existing
playerStore.progress characterization test (unchanged).
playerStore 3516 → 3490 LOC.
Three small tranches out of playerStore.ts:
- src/utils/waveformParse.ts — waveformBlobLenOk + coerceWaveformBins (the
parser that handles number[] / Uint8Array / ArrayLike payloads Rust
serializes as).
- src/utils/normalizationCompare.ts — normalizationAlmostEqual (tolerant
null-aware dB comparison).
- src/utils/seekErrors.ts — isRecoverableSeekError (retry classifier for
the Rust seek pipeline).
Each gets focused unit tests. All four were file-private, no external
callers — pure code move. playerStore 3556 → 3516 LOC.
Move the bounded undo/redo stacks, snapshot factory, scroll-top reader
registry, and pending-scroll-top channel into a dedicated module under
src/store/. playerStore re-exports the three public APIs that QueuePanel
and the test harness already imported (registerQueueListScrollTopReader,
consumePendingQueueListScrollTop, _resetQueueUndoStacksForTest), so no
caller-side changes are needed.
The new module adds explicit push/pop accessors so the undo/redo store
actions stop reaching into module-scoped arrays directly. Inline
`pendingQueueListScrollTop = …` writes inside applyQueueHistorySnapshot
become `setPendingQueueListScrollTop(…)` calls — same effect, scoped
through the module.
PlayerState gets the `export` keyword so queueUndo.ts can type its
state-shaped parameters.
playerStore.ts 3618 → 3576 LOC. queueUndo gains a focused test file
covering snapshot deep-cloning, the redo-stack invalidation on a fresh
undo push, max-size enforcement, and the scroll-top channel.
Pre-PR check: PASS.