Pull pure helpers, the contributors/maintainers data list and the
tab type/search index out of `Settings.tsx` so the main component
only has tab-section render logic + the orchestrating state left:
- `utils/audioDeviceLabels.ts` — five ALSA-device label helpers
(formatAudioDeviceLabel + duplicate-disambiguation + sort +
select-option builder).
- `utils/formatBytes.ts` — formatBytes + snapHotCacheMb.
- `components/settings/LoudnessLufsButtonGroup.tsx` — small chip
group used by the audio tab.
- `components/settings/settingsTabs.ts` — `Tab` type + legacy alias
map + `resolveTab` + `SearchIndexEntry` + `SETTINGS_INDEX` + the
`matchScore` substring-with-fuzzy-fallback scorer.
- `config/settingsCredits.ts` — `CONTRIBUTORS` (~270 LOC of static
contributor history) + `MAINTAINERS`.
Pure code-move. Settings.tsx: 3552 → 3037 LOC (−515). Settings/G
journey now 5298 → 3037 LOC (~43% reduction).
Three self-contained server/user-management components peel ~1000 LOC
out of `Settings.tsx`:
- `AddServerForm.tsx` (~163 LOC) — server URL + credentials + magic-
string paste form. Used by the Servers tab.
- `UserForm.tsx` (~340 LOC, with `initialUserFormState` + `UserFormState`
type) — full Navidrome user create/edit form including the
"save + copy magic string" admin flow.
- `UserManagementSection.tsx` (~485 LOC, with `formatLastSeen` helper) —
list + CRUD + Trash/Edit row + per-row magic-string-with-password
modal. Used by the Users tab. Imports `UserForm` directly.
Each component owns its own state, helpers, and modal-portal logic.
Settings.tsx now imports them and threads in props (server URL, admin
token, current username).
Pure code-move. Settings.tsx: 4568 → 3552 LOC (−1016). 18 unused
imports trimmed (navidromeAdmin types/functions, serverMagicString
helpers, ConfirmModal, lucide icons, createPortal).
Pull the 11 self-contained components at the bottom of Settings.tsx
out into `src/components/settings/`:
- `HomeCustomizer.tsx` — home-page section visibility toggles.
- `QueueToolbarCustomizer.tsx` — drag-to-reorder queue toolbar
buttons + per-button visibility. Includes the GripHandle + button
icons/labels tables.
- `SidebarCustomizer.tsx` — sidebar nav drag-reorder for library +
system blocks. Includes the GripHandle and the random-nav-mode
toggle.
- `LyricsSourcesCustomizer.tsx` — lyrics fetch pipeline UI (mode
switch + drag-reorder source list + static-only toggle).
- `ArtistLayoutCustomizer.tsx` — artist page section drag-reorder.
- `BackupSection.tsx` — export/import buttons with toast feedback.
- `ServerGripHandle.tsx` — single-purpose grip handle used by the
servers tab in the main Settings body.
Each component owns its own DnD plumbing, label tables and drop
target types. Settings.tsx now only imports the components; one
local `ServerDropTarget` type kept inside `Settings()` because the
main component still owns the server-list DnD state.
Pure code-move. Settings.tsx: 5298 → 4568 LOC (−730). 13 unused
imports trimmed (lucide icons, store types, shallow, layout helpers).
Move `apiWithCredentials` + `restBaseFromUrl` from `api/subsonic.ts`
into `api/subsonicClient.ts` where the other token-auth primitives
already live. They are the credential-bearing variants of `api`,
sitting in the same client module is the natural home.
`api/subsonic.ts` now holds only the connection-probing domain:
`ping`, `pingWithCredentials`, `probeInstantMixWithCredentials`,
`scheduleInstantMixProbeForServer`. Clear, cohesive, stable import
path for the call sites that still use it.
Pure code-move. subsonic.ts: 144 → 119 LOC. Total Phase F journey:
1333 → 119 LOC (~91% reduction).
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.