Commit Graph

1099 Commits

Author SHA1 Message Date
Frank Stellmacher e04980626d refactor(api): F.52 — move credential helpers to subsonicClient (#617)
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).
2026-05-13 01:08:48 +02:00
Frank Stellmacher f7b2799d39 refactor(api): F.51 — extract playlists + play queue + radio + statistics (#616)
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`.
2026-05-13 01:03:13 +02:00
Frank Stellmacher 9606a99efb refactor(api): F.50 — extract 7 small domain modules (#615)
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).
2026-05-13 00:46:13 +02:00
Frank Stellmacher 006635de4b refactor(api): F.49 — extract library + artists + ratings (#614)
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).
2026-05-13 00:23:53 +02:00
Frank Stellmacher 72030f17fd refactor(api): F.48 — extract subsonic types + client primitives (#613)
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).
2026-05-13 00:05:58 +02:00
Frank Stellmacher acdb0ae795 refactor(auth): E.47 — extract onRehydrateStorage migration block (#612)
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.
2026-05-12 23:50:48 +02:00
Frank Stellmacher 2c0aef9538 refactor(auth): E.46 — extract logic-bearing factories (#611)
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).
2026-05-12 23:44:17 +02:00
Frank Stellmacher fe6acdaa4c refactor(auth): E.45 — extract seven settings factories (#610)
Seven small domain-eng factories peel ~53 trivial setters out of the
authStore body:

- `createCacheStorageActions` — disk caches (max cache MB, download
  folder, offline download dir, hot-cache enable/MB/debounce/dir).
- `createDiscordSettingsActions` — rich presence, cover source,
  Bandsintown toggle, three template strings.
- `createUiAppearanceActions` — visual chrome: tray, titlebar, sidebar
  toggles, fullscreen-lyrics rendering, changelog banner, seekbar
  style, queue collapse, kinetic scroll, mini-player preload.
- `createLyricsSettingsActions` — lyrics fetch pipeline (server-first,
  netease enablement, source order, mode, static-only).
- `createTrackPreviewActions` — preview enable + per-location +
  start ratio (clamped 0…0.9) + duration (clamped 5…120s).
- `createDiscoveryActions` — mix min-rating filters (clamped 0…3),
  random-mix size (snapped to allowed bucket), lucky-mix visibility,
  random-nav mode, infinite-queue, preserve-play-next-order.
- `createPlumbingSettingsActions` — logging mode, getNowPlaying
  toggle, preload mode + custom seconds, audiobook exclusion,
  genre blacklist.

Pure code-move. authStore.ts: 428 → 384 LOC (−44).
2026-05-12 23:38:09 +02:00
Frank Stellmacher b8ddb09b78 refactor(auth): E.44 — extract server-profile + lastfm + audio-settings factories (#609)
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).
2026-05-12 23:30:28 +02:00
Frank Stellmacher 4d564e5016 refactor(auth): E.43 — extract authStore types + defaults + helpers (#608)
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).
2026-05-12 23:22:28 +02:00
Frank Stellmacher 9fac6eb490 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.
2026-05-12 22:46:13 +02:00
Frank Stellmacher e92535a5f5 refactor(player): E.41 — extract playTrack action (#605)
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).
2026-05-12 22:21:44 +02:00
Frank Stellmacher 3130a0ad74 refactor(player): E.40 — extract next action (#604)
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).
2026-05-12 22:07:11 +02:00
Frank Stellmacher 7e902b918c refactor(player): E.39 — extract resume action (#603)
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).
2026-05-12 21:46:20 +02:00
Frank Stellmacher 0aadae061f refactor(player): E.38 — extract seek action (#602)
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).
2026-05-12 21:38:03 +02:00
Frank Stellmacher 14346d1482 refactor(player): E.37 — extract updateReplayGainForCurrentTrack (#601)
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).
2026-05-12 21:14:24 +02:00
Frank Stellmacher 55b2b12fa5 refactor(player): E.36 — extract schedule-pause/resume factory (#600)
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).
2026-05-12 21:06:48 +02:00
Frank Stellmacher 1e2c651196 refactor(player): E.35 — extract misc-actions factory (#599)
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).
2026-05-12 21:01:12 +02:00
Frank Stellmacher 76fc3bb9c9 refactor(player): E.34 — extract transport-light + undo/redo factories (#598)
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).
2026-05-12 20:52:22 +02:00
Frank Stellmacher 168d5905c2 refactor(player): E.33 — extract queue mutation actions as factory (#597)
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).
2026-05-12 20:40:04 +02:00
Frank Stellmacher 0c2aa993f2 refactor(player): E.32 — extract UI state actions as factory (#596)
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.
2026-05-12 20:25:21 +02:00
Frank Stellmacher 2c5659b425 refactor(player): E.31 — extract Last.fm love actions as factory (#595)
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.
2026-05-12 20:17:59 +02:00
Frank Stellmacher 8dfe05fe7d refactor(player): E.30 — extract applyQueueHistorySnapshot (#594)
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.
2026-05-12 20:07:44 +02:00
Frank Stellmacher 6438fff019 refactor(player): E.29 — extract Track + PlayerState types (#593)
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).
2026-05-12 19:58:23 +02:00
Frank Stellmacher b0418bf920 refactor(player): E.28 — extract storage + hotkey helpers cluster (#592)
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.
2026-05-12 19:41:57 +02:00
Frank Stellmacher 013d6144ca refactor(player): E.27 — extract initAudioListeners into module (#591)
`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).
2026-05-12 19:25:34 +02:00
Frank Stellmacher 0cb7fba272 refactor(player): E.26 — extract Rust audio event handlers cluster (#590)
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).
2026-05-12 19:09:32 +02:00
Frank Stellmacher a5aadeea67 refactor(player): E.25 — extract audio-orchestration cluster (#589)
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.
2026-05-12 18:08:09 +02:00
Frank Stellmacher 14bdcde33f refactor(player): E.24 — extract radio session + infinite-queue guards (#588)
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.
2026-05-12 17:26:15 +02:00
Frank Stellmacher 6355946610 refactor(player): E.23 — extract engine state + seek debounce cluster (#587)
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).
2026-05-12 17:14:37 +02:00
Frank Stellmacher 0eb084c5f8 refactor(player): E.22 — extract seek-fallback retry + visual target state (#586)
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.
2026-05-12 17:01:22 +02:00
Frank Stellmacher c10eabe114 refactor(player): E.21 — extract playback-progress throttle timestamps (#585)
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.
2026-05-12 16:45:57 +02:00
Frank Stellmacher 6d07720b4f refactor(player): E.20 — extract HTML5 radio player (#584)
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.
2026-05-12 16:28:10 +02:00
Frank Stellmacher 7dc4888a06 refactor(player): E.19 — extract analysis-refresh helpers cluster (#583)
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.
2026-05-12 16:15:34 +02:00
Frank Stellmacher 4c64844349 refactor(player): E.18 — extract three transport-coordination modules (#581)
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.
2026-05-12 15:23:40 +02:00
Frank Stellmacher 9029ab8ec5 refactor(player): E.17 — extract stream-cache-to-hot-cache promoter (#580)
`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.
2026-05-12 15:02:46 +02:00
Frank Stellmacher 3a99be4daa refactor(player): E.16 — extract gapless-preload coordination state (#579)
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).
2026-05-12 14:53:37 +02:00
Frank Stellmacher 2ad0be6a71 refactor(player): E.15 — extract server queue sync into module (#578)
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.
2026-05-12 14:43:53 +02:00
Frank Stellmacher 08d098d5aa refactor(player): E.14 — extract loudness-backfill-window helpers (#577)
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.
2026-05-12 14:36:55 +02:00
Frank Stellmacher c88649836e refactor(player): E.13 — extract loudness-backfill retry state (#576)
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).
2026-05-12 14:29:33 +02:00
Frank Stellmacher 85df3e42c1 refactor(player): E.12 — extract skip-to-1★ helper (#575)
`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.
2026-05-12 14:22:38 +02:00
Frank Stellmacher ddead24678 refactor(player): E.11 — extract two playback-coordination helpers (#574)
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.
2026-05-12 14:14:50 +02:00
Frank Stellmacher 86b13dd4d0 refactor(player): E.10 — extract normalization-IPC deduplicators (#573)
`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.
2026-05-12 14:07:44 +02:00
Frank Stellmacher 89bf7e2364 refactor(player): E.9 — extract scheduled pause/resume timer lifecycle (#572)
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.
2026-05-12 14:01:36 +02:00
Frank Stellmacher 18b88e3ae0 refactor(player): E.8 — extract loudness-gain cache encapsulation (#571)
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.
2026-05-12 13:54:41 +02:00
Frank Stellmacher a1d7cf330d refactor(player): E.7 — extract two small file-private helpers (#570)
`emitNormalizationDebug` (debug-mode trace forwarder, 15+ internal call
sites) and `isInOrbitSession` (Orbit-active guard used by next() and the
async fallback paths to suppress local queue extensions, 5 call sites)
move into dedicated modules under src/store/. Both were file-private —
no caller-side changes outside playerStore's own imports.

playerStore 3434 → 3415 LOC.
2026-05-12 12:35:01 +02:00
Frank Stellmacher 81b161a418 refactor(player): E.6 — extract deriveNormalizationSnapshot into module (#569)
`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.
2026-05-12 12:21:51 +02:00
Frank Stellmacher b68bddd034 refactor(player): E.5 — extract playback-URL routing into module (#568)
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.
2026-05-12 12:07:30 +02:00
Frank Stellmacher 5b89051817 refactor(player): E.4 — extract playback-progress pub/sub into module (#567)
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.
2026-05-12 11:58:17 +02:00
Frank Stellmacher d24514d67e refactor(player): E.3 — extract waveform/normalization/seek pure helpers (#566)
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.
2026-05-12 11:45:09 +02:00