Commit Graph

320 Commits

Author SHA1 Message Date
Frank Stellmacher 606a150e01 feat(settings): clock format setting (Auto / 24h / 12h) (#742)
* feat(settings): clock format setting (Auto / 24h / 12h)

Reported on the Psysonic Discord — the Queue side panel's ETA label
and the sleep-timer preview both render via `formatClockTime`, which
just calls `toLocaleTimeString` and so follows the user's system
locale. On en-US that means AM/PM, with no in-app way out.

Add a tri-state **Clock Format** setting under
**Settings → System → App Behavior**:

* `auto` (default) — keep the existing locale-driven behaviour, so
  bestehende installs are unaffected on first launch.
* `24h` — force 24-hour wall-clock output everywhere
  `formatClockTime` is used.
* `12h` — force AM/PM output.

Wired through `authStore` (`clockFormat`, `setClockFormat`), exposed
via `CustomSelect` in `SystemTab`, and threaded into the two
consumers (`QueueHeader`, `PlaybackDelayModal`) so they re-render on
change. `formatClockTime` itself stays a pure helper — it accepts the
setting as an optional second argument and maps it to `hour12`.

Locale coverage: all nine bundled locales (en, de, es, fr, nl, nb,
ru, zh, ro) get the four new settings strings. Pin tests added for
the `setClockFormat` setter and the `hour12` mapping in
`formatClockTime`.

* docs(changelog): clock format setting + contributors (PR #742)
2026-05-17 01:26:40 +02:00
cucadmuh 6ea0acede5 feat(playback): stream buffering UI, M4A moov-at-end streaming, hot-cache spill (#737)
* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix

Defer seekbar/progress until HTTP stream is armed for both legacy and
RangedHttpSource; show buffering overlay on cover art. Add MP4 tail
prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so
moov-at-end M4A can start without reading the full mdat.

* feat(hot-cache): spill large ranged streams to disk for promote

When a ranged HTTP download completes above the 64 MiB RAM promote cap,
write the existing buffer once to app-data stream-spill/ and register it
for hot-cache promote (rename) and replay via fetch_data. Analysis seeds
from the spill file up to the local-file cap (512 MiB).

* fix(ui): stream buffering — grayscale cover and static clock icon

Desaturate player and queue cover art while isPlaybackBuffering; keep a
non-animated clock overlay for visibility without the spinning animation.

* fix(playback): review follow-up — tests, i18n, spill cleanup, changelog

Clippy and test layout fixes; stream spill orphan cleanup on startup;
buffering flag guard in progress handler; bufferingStream in all player
locales; CHANGELOG and contributor credits for stream/M4A work.

* docs: attribute stream buffering and M4A streaming to PR #737

* test(audio): avoid create_engine in stream spill unit test

CI runners have no audio output device; test spill take/consume via
the Mutex slot only, matching install_stream_completed_spill tests.
2026-05-16 22:56:47 +03:00
cucadmuh 603660d407 fix(queue): Lucky Mix uses one undo snapshot for Ctrl+Z (#728)
Many prune/play/enqueue steps exhausted QUEUE_UNDO_MAX and dropped the
pre-mix snapshot. Push undo once before the macro rebuild; add skipQueueUndo
for enqueue and pruneUpcomingToCurrent; Lucky Mix playTrack uses manual=false.

CHANGELOG: document fix under 1.46.0 Fixed (PR #728).
2026-05-15 21:32:04 +03:00
Frank Stellmacher ccf4d5d8cb feat(queue): persist queue header duration mode (#724)
* feat(queue): persist header duration mode (#625)

The queue header chip cycles total / remaining / ETA, but the choice
lived in QueuePanel `useState` and reset to 'total' on every app launch.
Move it into `authStore` (persisted under `psysonic-auth`) so the chosen
mode survives restarts like other UI preferences.

- `DurationMode` consolidated in `authStoreTypes` (re-exported from
  `queuePanelHelpers` so existing component imports stay valid).
- New `queueDurationDisplayMode` field + `setQueueDurationDisplayMode`
  setter wired through `createUiAppearanceActions`, default `'total'`.
- `computeAuthStoreRehydration` validates the persisted value against
  the three known modes (pattern matches the existing seekbarStyle
  block) — garbage, null, undefined, or a missing key map back to
  `'total'` so the chip never receives an unknown mode.
- `QueuePanel` reads / writes via `useAuthStore` selectors instead of
  local `useState`.
- Tests: trivial-setter coverage in `authStore.settings.test.ts` and a
  new `authStoreRehydrate.test.ts` covering corrupt, missing, and
  valid rehydration cases.

Reuses kveld9's design from PR #625; not merged because the 1.46
refactor split locales and reshuffled queue-helper exports. Credited
via Co-Authored-By trailer.

Co-Authored-By: Kveld. <kveld912@proton.me>

* docs(changelog): queue header duration mode persistence (#724)

Adds the CHANGELOG entry + kveld9 credits line referencing the real PR
numbers (no #TBD placeholder) — feedback.md §2.7+§2.8 workflow: commit
docs as a second push onto the open PR.

---------

Co-authored-by: Kveld. <kveld912@proton.me>
2026-05-15 19:14:06 +02:00
Frank Stellmacher 2d27428056 feat(settings): player bar layout — per-control visibility toggles (#627) (#721)
Adds a new sub-section under Settings → Personalisation (Advanced) that
hides individual controls in the player bar: Star rating, Favorite
(heart), Last.fm love, Equalizer, Mini player. Last.fm love still only
renders when a Last.fm session exists; the overflow row in the player
collapses when both Equalizer and Mini player are hidden.

- New `playerBarLayoutStore` (Zustand + persist, items[{id, visible}] +
  rehydrate sanitize) following the queueToolbar / playlistLayout
  pattern; defaults to all visible.
- New `PlayerBarLayoutCustomizer` reuses the same row + toggle pattern
  as the other personalisation customisers.
- Gates threaded through `PlayerTrackInfo` (3 controls), `PlayerBar`
  (EQ + Mini buttons), and `PlayerOverflowMenu` (EQ + Mini in the
  overflow row, with row-level conditional).
- `PersonalisationTab`: added as the last advanced sub-section so it
  only appears when the global Advanced Mode toggle is on.
- Settings search index gets entries for both Playlist page layout and
  Player bar (playlist row was missing).
- New i18n keys `settings.playerBar*` in all 9 locales.

Reuses kveld9's design from PR #627; not merged because the locale
split and the Advanced Mode refactor landed afterwards. Credited via
Co-Authored-By trailer + a new line in settingsCredits.ts under the
existing kveld9 entry.

Co-authored-by: Kveld. <kveld912@proton.me>
2026-05-15 17:48:32 +02:00
Frank Stellmacher 651a3f276a feat(settings): global Advanced Mode toggle + playlist page layout (#556) (#720)
Adds a per-element visibility toggle for the playlist detail page (Add
Songs, Import CSV, Download ZIP, Cache Offline, Suggestions) and reworks
the way uncommon options are surfaced: instead of a per-tab collapsible
group, a global "Advanced" toggle in the Settings header reveals all
`advanced` sub-sections across every tab and marks each one with a small
badge. Sets the pattern up so any future advanced option lives in its
natural tab, gated by the same switch.

- New `advancedSettingsEnabled` boolean on `authStore`
  (UiAppearance slice, persisted with the rest of the store).
- `SettingsSubSection` gains an `advanced?: boolean` prop. Hidden when
  the toggle is off; renders an "Advanced" pill in the header when on.
- Settings header gets a Toggle-Switch next to the search lupe.
- `PersonalisationTab` flattens — Sidebar + Home stay always visible;
  Artist sections, Queue Toolbar, and the new Playlist layout get
  `advanced` and disappear by default. `PersonalisationAdvancedGroup`
  component + CSS removed.
- New `playlistLayoutStore` (Zustand + persist, items[{id,visible}] +
  rehydrate sanitize) following the queueToolbarStore pattern.
- `PlaylistHero` and `PlaylistSuggestions` gate the four toolbar buttons
  and the suggestions rail on the store directly.
- One-time migration in MainApp on mount: if the user had opened the
  old per-tab Advanced group (`psysonic_personalisation_advanced_open
  === 'true'`) OR already customised any of the three sub-sections,
  Advanced Mode auto-enables on first launch. Idempotent via a
  localStorage flag; legacy key removed afterwards.
- New i18n keys `settings.advancedMode`, `settings.advancedModeTooltip`,
  `settings.advancedBadge`, `settings.playlistLayout*` in all 9 locales.

Reuses kveld9's design from PR #556; not merged because the locale split
landed afterwards. Credited under the existing kveld9 entry in
settingsCredits.ts.

Co-authored-by: Kveld. <kveld912@proton.me>
2026-05-15 15:55:11 +02:00
cucadmuh 45e0e1206f fix(playback): pin queue playback to source server when browsing another library (#717)
* fix(playback): pin queue streams, cover art, and library links to queue server

When the active server changes while a queue from another server is playing,
keep streams and UI on queueServerId; switch back for artist/album links and
queue or player-bar context menus.

* fix(playback): switch to queue server when opening Now Playing

Ensure active server matches queueServerId before Subsonic fetches on the
Now Playing page, mobile player route, and queue info panel; scope caches
by server id.

* docs(credits): mention Now Playing in PR #717 contribution line

* fix(playback): route scrobble and queue sync to queue server

Address PR review: apiForServer for scrobble/now-playing/savePlayQueue,
clear queueServerId on server removal, mini-player queueServerId sync,
block cross-server enqueue with toast, and regression tests.
2026-05-15 16:08:41 +03:00
Psychotoxical c9e4c00a68 docs: correct libraryGridMaxColumns clamp range in JSDoc (4-12) 2026-05-15 01:38:24 +02:00
Maxim Isaev 0f5ece6d03 feat(settings): library card grid max columns in Appearance (4–12, default 6)
Persist libraryGridMaxColumns, wire useCardGridMetrics and card grid layout,
add i18n and settings search index; document performance hint in copy.
2026-05-15 02:19:20 +03:00
cucadmuh 3cc172723d fix(ui): split album and track artists (OpenSubsonic) (#696)
* fix(ui): split OpenSubsonic album and track artists in header and player

Album detail header uses albumArtists from album or child songs; player bar,
mobile player, and mini player use structured track artists with per-id links.
Adds deriveAlbumHeaderArtistRefs helper and OpenArtistRefInline.

Fixes #552

* docs: changelog and credits for OpenSubsonic artist links (PR #696)
2026-05-14 21:56:39 +03:00
Frank Stellmacher b4c8ed4b65 fix(offline): cancellable downloads + stable sidebar progress toast (#694)
* fix(sidebar): keep offline-download toast from squishing in a short window

The toast lives in the sidebar nav flex column; without flex-shrink: 0 the
column compressed it vertically when the main window was small. The label
now also ellipsis-truncates instead of overflowing on a narrow sidebar.

* fix(offline): make offline downloads cancellable down to the Rust transfer

A running offline download could not be stopped — the sidebar X button only
dropped not-yet-started tracks between batches of 8, and the Rust transfer had
no cancellation path at all, so in-flight HTTP streams always ran to completion.

Add an offline_cancel_flags() registry (mirroring sync_cancel_flags for the
device-sync side) plus additive cancel_offline_downloads / clear_offline_cancel
commands. download_track_offline takes an optional download_id, checks the flag
right after acquiring its semaphore slot, and threads it through
finalize_streamed_download / stream_to_file so an in-flight stream aborts at the
next chunk — the partial .part file is cleaned up by the existing error path.

* fix(offline): cancel per-track and clear the sidebar toast immediately

downloadAlbum tags each run with a downloadId, checks for cancellation before
every track instead of once per 8-track batch (which never re-ran for albums of
8 or fewer tracks), and persists tracks that finished before the cancel so they
are not orphaned on disk. cancelDownload / cancelAllDownloads drop every job for
the album and call cancel_offline_downloads so Rust aborts the in-flight
transfers — the toast disappears at once instead of lingering on stuck rows.

Adds offlineJobStore cancellation tests.

* docs(changelog): offline download cancel button + toast sizing fixes
2026-05-14 20:08:08 +02:00
Frank Stellmacher 7a7a9f5e6b refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
2026-05-14 14:27:44 +02:00
Frank Stellmacher 85e9ee6a94 refactor(audio-listeners): split initAudioListeners into per-concern setup modules (#685)
Each listener/subscription concern moves into its own module under
store/audioListenerSetup/; initAudioListeners just composes them in the
original setup / teardown order:

- audioEngineListeners  audio:* + analysis:* Tauri listeners
- initialAudioSync      one-shot startup sync to the Rust engine
- authSyncListener      auth-store + analysis-storage subscriptions
- mprisSync             MPRIS / OS media-controls sync
- radioMprisMetadata    radio ICY StreamTitle -> MPRIS
- discordPresence       Discord Rich Presence sync

Verbatim code-move — listener registration, handler bodies, local state
and the exact cleanup order are all preserved. The MainApp call site and
the three playerStore.*.test.ts suites import initAudioListeners
unchanged.
2026-05-14 13:21:54 +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