1661 Commits

Author SHA1 Message Date
Frank Stellmacher 58d3bcd695 fix(queue-info): show artist image for current track (#732)
* fix(queue-info): pin artist image cache key to matching info, not lagging state

`artistInfo` state and `artistId` updated on different cycles, so on
track change the info panel rendered one frame with the previous
track's `largeImageUrl` under the new `heroCacheKey`. CachedImage's
IndexedDB persisted that mismatched blob under the new key, leaving
every subsequent track stuck on the previous artist's image.

Hold artist info + song detail as `{ id, info }` tuples and gate
render on id-match so `src` and `cacheKey` always come from the
same source.

* docs(changelog): queue info artist image fix (PR #732)
2026-05-16 02:39:14 +02:00
Frank Stellmacher efd85ffde3 feat(tracklist): play count, last played, BPM columns + Song Info rows (#730)
* feat(tracklist): play count, last played, and BPM columns (#516)

Adds three opt-in columns to Album / Playlist / Favorites tracklists,
plus the same fields in the Song Info modal. Picks up Navidrome's
existing `playCount` / `played` / `bpm` from the Subsonic response — no
new API calls.

- `SubsonicSong` gains `playCount`, `played`, `bpm` (already populated
  by Navidrome's Subsonic API, just unmapped in the TS model).
- `albumTrackListHelpers.COLUMNS`, `PL_COLUMNS` (PlaylistDetail), and
  `FAV_COLUMNS` (Favorites) get the three new entries. Genre also added
  to the playlist column set for parity with the other two lists.
- Render uses `.track-duration` (12px tabular, centered, muted) for the
  numeric stats and `.track-genre` (11px text, muted) for the relative
  last-played timestamp via the existing `formatLastSeen` helper.
- Sort logic extended in `playlistDisplayedSongs`,
  `useAlbumDetailSort`, and `useFavoritesSongFiltering` for the three
  new keys.
- `SongInfoModal` gets matching rows (BPM / Play count / Last played).
- `useTracklistColumns.gridTemplate` / `gridMinWidth` fall back to the
  ColDef's `defaultWidth` when a visible column has no saved width
  (newly added column on an old prefs blob would otherwise emit
  `undefinedpx` and collapse the row layout until reset-to-defaults).
- BPM cells skip the rendering when Navidrome returns 0 (default for
  untagged files) — show `—` instead of `0`.
- i18n: `albumDetail.trackPlayCount / trackLastPlayed / trackBpm` and
  `songInfo.playCount / lastPlayed / bpm` in all 9 locales.

Suggested by jbigginswyl (#516).

* docs(changelog): tracklist Plays / Last played / BPM columns (#730)
2026-05-15 22:01:05 +02: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 c16134dab5 fix(ui): consolidate Orbit / Server / Live header dropdowns (#725)
* fix(ui): consolidate Orbit / Server / Live header dropdowns

The three header dropdown popovers each had their own container style.
Live used a glass utility class with backdrop-filter that read poorly on
many themes (reported by cucadmuh). Move all three onto the shared
`.nav-library-dropdown-panel` container — same bg, border, shadow and
radius via existing semantic tokens (`--bg-card`, `--border-dropdown`,
`--shadow-dropdown`, `--radius-md`). Item styles per dropdown stay
case-specific.

- NowPlayingDropdown: replace `glass animate-fade-in` with
  `nav-library-dropdown-panel animate-fade-in`; drop now-redundant inline
  borderRadius / boxShadow / zIndex; keep padding + gap for the
  user-list breathing room.
- OrbitStartTrigger: add `nav-library-dropdown-panel` alongside
  `orbit-launch-pop`. The component-local class is now reduced to the
  menu-specific min-width + tighter item gap; bg / border / shadow /
  radius / padding inherit from the shared container.
- Drop the unused `.glass` utility class; no other call sites.

* docs(changelog): header dropdown consistency fix (#725)
2026-05-15 19:54:42 +02: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
cucadmuh 187170057d fix(build): static import ratings cache invalidate; raise chunk warning limit (#723)
Replace ineffective dynamic import of subsonicRatings from setRating (mix
paths already statically load it). Set Vite chunkSizeWarningLimit to 1000 kB
for desktop bundles.
2026-05-15 20:06:10 +03:00
Frank Stellmacher d32b3a52dd docs(changelog): link player bar PR #721 (#722) 2026-05-15 18:20:45 +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
cucadmuh ea6ac49885 feat(offline): Offline Library shows cached albums from all servers (#719)
* feat(offline): show cached albums from all servers in Offline Library

List every offline album regardless of active server; load cover art per
source server and switch before play/enqueue. Sidebar, mobile nav, and
disconnect auto-nav use any cached content; multi-server cards show a label.

* docs(changelog): link offline library PR #719

* chore(pr-719): address review — CHANGELOG in Added, helper tests

Move release note to ## Added per team changelog policy; cover
offlineAlbumCoverArt and ensureServerForOfflineAlbum in unit tests.
2026-05-15 17:20:19 +03: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 e1283f4068 fix(ui): selectstart blocker handles Text node event targets (#718)
* fix(ui): handle Text node targets in global selectstart blocker

selectstart can target a Text node without closest(); resolve to the
parent Element before checking inputs and data-selectable regions.

* docs(changelog): note PR #718 selectstart Text node fix

* fix(ui): narrow selectstart target to Node before parentNode

Satisfies tsc: EventTarget has no parentElement/parentNode until
narrowed with instanceof Node.
2026-05-15 16:21:38 +03: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
cucadmuh a9b50b9244 Merge pull request #716 from Psychotoxical/feat/search-share-link-queue
feat(search): queue pasted share links from Live Search and mobile search
2026-05-15 13:56:34 +03:00
Maxim Isaev 33b0206ea2 feat(search): queue preview on Ctrl+V paste and modal polish
Global queue paste opens the preview modal before play; overlay scrollbar,
context-menu suppression, changelog/credits for PR #716 and DanielWTE (#551).
2026-05-15 13:52:07 +03:00
Maxim Isaev e7431b94b8 feat(search): queue pasted share links from Live Search and mobile search
Implement share-link detection in search (track, queue, album, artist,
composer): enqueue tracks/queues without interrupting playback; preview
album/artist/composer without switching the active server; queue preview
modal with scrollable track list. Based on community PR #551.

Co-authored-by: Daniel Wagner <daniel.iuser@icloud.com>
2026-05-15 13:38:35 +03:00
cucadmuh 02fd828049 fix(mix): rating filter across mixes and Lucky Mix queue fill (#714)
* fix(mix): apply rating filter across mixes and fix Lucky Mix queue fill

Invalidate entity rating cache on setRating and stop negative-caching
unrated artists/albums so filters see fresh stars. Honor UI rating
overrides, wire Instant Mix and CLI paths, fix Random Mix filter order,
and align Lucky Mix progress with the real player queue length.

* docs(changelog): document mix rating filter and Lucky Mix queue fix
2026-05-15 12:45:02 +03:00
cucadmuh cfcbbd79e4 fix(ui): flush playlist context submenus to parent row (#713)
* fix(ui): place playlist context submenus flush to trigger row

Use left/right/top 100% instead of calc(100% + 4px) so there is no dead
gap when moving the pointer from Add to playlist into the submenu.

* fix(ui): defer closing playlist submenu on trigger mouseleave

Use a short timer and :hover on the trigger row so slow moves across
border/subpixel gaps still reach the nested submenu; cancel timer on
re-enter and when the context menu closes.
2026-05-15 03:13:40 +03:00
cucadmuh f275ce4910 Merge pull request #712 from Psychotoxical/docs/changelog-pr711-library-card-grids
docs(changelog): document PR #711 library card grids
2026-05-15 02:55:53 +03:00
Maxim Isaev 4d37fea163 docs(changelog): document PR #711 library card grid virtualization
Add Added-section entry for VirtualCardGrid rollout, affected pages, and
Appearance setting for maximum grid columns (4–12, default 6).
2026-05-15 02:54:41 +03:00
cucadmuh a291d747b2 Merge pull request #711 from Psychotoxical/experiment/card-grid-max-six-virtual
feat(ui): virtualized library card grids and configurable column cap
2026-05-15 02:44:51 +03:00
Psychotoxical c9e4c00a68 docs: correct libraryGridMaxColumns clamp range in JSDoc (4-12) 2026-05-15 01:38:24 +02:00
Maxim Isaev 35f031f573 merge: sync main into experiment/card-grid-max-six-virtual 2026-05-15 02:24:28 +03: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
Frank Stellmacher a7a3148949 fix(format): round human duration totals to the nearest minute (#710)
* fix(format): round human duration totals to the nearest minute

The Phase L dedup refactor folded the old `formatAlbumDuration` helper
into `formatHumanHoursMinutes`, but the shared version truncated seconds
to whole minutes instead of rounding. Aggregate duration labels (album,
playlist, total playtime) could read up to ~59 s short and flip the
hour boundary the wrong way — a 59:30 total showed "59 m" instead of
"1 h 0 m".

Restore the round-to-nearest-minute behaviour (and the negative-input
clamp) the helper had before the consolidation. Adds a test pinning the
rounding and the hour-boundary roll-up so it can't regress again.

* docs(changelog): add human-duration rounding fix under Fixed (#710)
2026-05-15 01:06:46 +02:00
Maxim Isaev 7c6d3694d4 feat(ui): shared card grid with max six columns and virtualization
Add cardGridLayout helpers, useCardGridMetrics, useRemeasureGridVirtualizer, and VirtualCardGrid (TanStack row virtualization, always remeasure on layout changes).

Apply to Artists (grid + list unchanged policy), Albums, Composers grid, playlists, radio stations, offline library, and album-heavy browse/detail pages. Respect disableMainstageVirtualLists for a non-virtual grid with the same column rules.

Includes vitest coverage for column cap.
2026-05-15 02:05:34 +03:00
cucadmuh 778c9e00fd fix(artists): restore infinite scroll after initial library load (#709)
* fix(artists): attach infinite-scroll observer when sentinel mounts

The Artists page only renders the bottom sentinel after getArtists finishes.
The hook subscribed in an effect keyed on loadMore; unlike Albums, that
callback does not depend on loading, so the observer never attached after
the first paint. Use a callback ref and observe against the main scroll
viewport (#app-main-scroll-viewport).

* docs: changelog for Artists infinite scroll fix (PR #709)
2026-05-15 01:23:51 +03:00
Frank Stellmacher 1bc0b3644d fix(audio): end track on sample-accurate exhaustion, not the floored duration hint (#708)
* fix(audio): end track on sample-accurate exhaustion, not the floored duration hint

With gapless and crossfade both disabled, the end of every track was cut
short by up to ~1 s. The progress task had two competing end-of-track
signals and the wrong one won:

- the duration-hint timer fired audio:ended at exactly the Subsonic
  duration, which is floored to whole seconds while the decoded audio
  almost always runs slightly longer; and
- the sample-accurate NotifyingSource `done` flag, which gapless already
  relies on, was only consulted when a chained successor existed.

Now the exhaustion branch emits audio:ended directly when the source is
done and no chain is queued — the real, sample-accurate track end. The
duration-hint timer is kept only as the crossfade trigger (it must fire
early, before the source exhausts) and as a watchdog for sources that
never signal exhaustion.

Adds three progress_task tests covering immediate end on exhaustion,
no premature end without crossfade, and the preserved crossfade trigger.

* docs(changelog): add end-of-track clipping fix under Fixed (#708)
2026-05-15 00:19:00 +02:00
Frank Stellmacher ac21fc084d feat(http): enable gzip + brotli decompression for reqwest clients (#704)
* feat(http): enable gzip + brotli decompression for reqwest clients

All Rust-side HTTP clients now advertise Accept-Encoding and transparently
decode compressed responses. reqwest auto-decompresses by default once the
features are enabled, so this is a pure dependency-feature change with no
call-site edits.

Added to all five reqwest declarations across the Cargo workspace
(top psysonic crate + psysonic-audio / -analysis / -integration / -syncfs).
The real wire savings land on JSON payloads — Navidrome native /api,
Bandsintown, Radio-Browser, Last.fm — measured at roughly -76% to -93% on
earlier curl tests. Crates that only fetch already-compressed audio bytes
get the features too for consistency: reqwest just advertises the header
there, so there's no runtime cost when the server returns data as-is.

Cargo.lock grows additively (async-compression + compression codecs); no
other crates moved.

* docs(changelog): add entry for HTTP gzip + brotli (#704)
2026-05-14 22:17:02 +02:00
cucadmuh 5f0803e98a fix(ui): stable React list keys on Now Playing cards (#703)
* fix(ui): use stable list keys on Now Playing dashboard cards

Subsonic payloads can repeat the same id in similar artists, album
track rows, and top songs. Keys now combine id with list index so
React reconciliation stays stable and duplicate-key warnings stop.

* docs: changelog and credits for Now Playing list keys (PR #703)

Document the dashboard list key fix in CHANGELOG and Settings contributors.
2026-05-14 23:12:55 +03:00
Frank Stellmacher 7879369150 docs(changelog): add Frontend refactor entry under Changed (#702) 2026-05-14 22:01:35 +02:00
Frank Stellmacher f9bb1b44b0 docs(changelog): add Under the Hood block for refactor + test suite (#701) 2026-05-14 21:55:56 +02:00
Frank Stellmacher 77f6933410 fix(settings): sort the contributors list chronologically (#700)
* fix(settings): sort the contributors list chronologically

The Settings → System contributors list rendered the array in raw
insertion order, so Psychotoxical (since v1.0.0) showed up last and
hand-maintained ordering drifted over time.

Sort on export instead: ascending by the `since` app version (reusing
isNewer), tie-broken by the first-contribution PR number. The list
stays correctly ordered regardless of where new entries are inserted.

* docs(changelog): add entry for contributors list sort fix (#700)
2026-05-14 21:45:34 +02:00
cucadmuh f054fe425c fix(radio): portal add/edit station modal to avoid clipping (#699)
* fix(radio): portal add/edit station modal to document.body

* docs: changelog and credits for radio edit modal portal (PR #699)
2026-05-14 22:26:44 +03:00
Frank Stellmacher d94e5c75b3 fix(internet-radio): restore delete button styling on station cards (#698)
The delete button referenced the CSS classes `playlist-card-delete` /
`playlist-card-delete--confirm`, which were renamed to
`playlist-card-action--delete` / `--delete-confirm` long ago when
PlaylistCard was reworked. InternetRadio was missed, so the button
rendered unstyled and effectively invisible.

Wrap it in `.playlist-card-actions` and use the current classes,
matching PlaylistCard — no new CSS needed.
2026-05-14 21:24:51 +02:00
cucadmuh 5d53a63553 fix(search): hide search3 artists with zero albums (#697)
* fix(search): hide search3 artists with zero albums

* docs: changelog and credits for search zero-album filter (PR #697)
2026-05-14 22:17:26 +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
cucadmuh ecdbe0cf2a fix(player): stale cover blob and load state on track change (#695)
* fix(player): align cached cover URL with cacheKey on track change

Prevents a one-frame stale blob src (and broken image in the player bar)
when switching tracks; reset CachedImage load state in useLayoutEffect.

* docs: changelog + credits for cover-art track-switch fix (PR #695)
2026-05-14 21:38:56 +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 946528350c refactor(orbit + shortcuts): unify host/guest heartbeat, document shortcut contract (Phase I) (#693)
* refactor(orbit): unify host/guest outbox heartbeat into a shared hook (Phase I)

The outbox-heartbeat effect was duplicated near-verbatim in useOrbitHost
and useOrbitGuest — same 10 s interval, same writeOrbitHeartbeat call,
same cleanup; the only difference is whose name owns the outbox
(OrbitState.host vs the active-server username).

Extract it into useOrbitOutboxHeartbeat(active, outboxPlaylistId,
sessionId, ownName). Host and guest each pass their own name source.
The push/pull state-tick logic stays untouched — that asymmetry is the
real host/guest difference, not duplication.

Behaviour-preserving: the owner name is now a reactive hook arg instead
of a getState() read inside the effect, so the heartbeat starts as soon
as the name is available rather than waiting for an unrelated dep to
change — a strict improvement, unreachable in practice since host name
and username are fixed per session.

* docs(shortcuts): document the shortcut-actions contract (Phase I)

Add a contract reference block to the shortcutActions barrel — the three
independent trigger surfaces (inApp / global / runInMiniWindow), the
surface-independent cli + run fields, the dispatch entry points — and
per-field doc comments on ShortcutActionMeta / ShortcutSlot /
ActionContext / CliContext in shortcutTypes.ts.

Pure documentation, no code change.
2026-05-14 15:37:26 +02:00
Frank Stellmacher 4b1dd3c29f refactor(dedup): consolidate byte / sanitize / clock / album-duration helpers (Phase L, part 2) (#692)
Findings 5-8 of the dedup audit:

- F5 byte formatters: appUpdaterHelpers.fmtBytes + ZipDownloadOverlay
  .formatMB route through the existing formatBytes; a new formatMb
  (always-MB) backs playlistDetailHelpers.formatSize, AlbumHeader and
  the 4 inline DeviceSyncPreSyncModal expressions. SongInfoModal.format
  Size is intentionally left — it uses decimal (1e6) divisors, not 1024.
- F6 sanitizeHtml: extracted to utils/sanitizeHtml.ts; AlbumHeader,
  ComposerDetail and the (now-empty, deleted) artistDetailHelpers use it
  directly. nowPlayingHelpers keeps its own export but now delegates to
  the shared sanitiser and only adds its trailing-link strip on top.
- F7 album duration: BecauseYouLikeRail's formatAlbumDuration drops in
  favour of the shared formatHumanHoursMinutes. Behaviour note: total
  minutes now floor instead of round (<=1 min display difference,
  matches every other caller).
- F8 clock time: extracted to utils/format/formatClockTime.ts;
  PlaybackDelayModal + QueueHeader use it (toLocaleTimeString and
  Intl.DateTimeFormat produced identical output).

Behaviour preserved except the two explicitly noted divergences (F7
round->floor; F5 appUpdater/Zip now show GB above 1 GB instead of a
large MB number).
2026-05-14 15:19:42 +02:00
Frank Stellmacher 0153435787 refactor(dedup): route inline shuffle + union-dedupe through existing utils (Phase L, part 2) (#691)
Findings 3 + 4 of the dedup audit — replace hand-rolled copies with the
utils that already exist:

- shuffleArray (utils/playback/shuffleArray.ts): BecauseYouLikeRail's
  local shuffle<T>, plus the inline Fisher-Yates loops in RandomAlbums,
  AlbumDetail and Home.
- dedupeById (utils/dedupeById.ts): the identical seen-Set/filter
  union-dedupe block in the fetchByGenres of Albums, NewReleases and
  RandomAlbums.

The extracted utils are character-identical to the inline loops, so no
behaviour change. RandomMix's biased `.sort(() => Math.random() - 0.5)`
is intentionally left alone — swapping it would change behaviour.
2026-05-14 14:58:26 +02:00
Frank Stellmacher 5231169a71 refactor(format): consolidate duration formatters into format/formatDuration (Phase L, part 2) (#690)
The mm:ss track-time formatter was hand-rolled in 11 places and the
h:mm:ss total-duration formatter in 4 — extract two tested functions:

- formatTrackTime(seconds, fallback='0:00')  — m:ss, used for track /
  playback times. fallback param covers the '–' placeholder rows.
- formatLongDuration(seconds)                — h:mm:ss when >=1h, else
  m:ss, used for album / queue totals.

Behaviour preserved per call site: the unified guard
(!seconds || !isFinite || <0 -> fallback) produces identical output to
every prior variant for all real inputs; SongRow keeps its '–' via the
fallback arg. Removes the formatter exports from 6 componentHelpers
files (playerBarHelpers / fullscreenPlayerHelpers deleted — they only
exported the formatter) and 7 inline component copies.

+ formatDuration.test.ts
2026-05-14 14:50:10 +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 2409a1fec8 refactor(i18n): split locale files into per-namespace modules (Phase K) (#688)
Each src/locales/<lang>.ts (~1800 LOC) becomes a folder src/locales/<lang>/
with one module per i18n namespace (44 each) plus an index.ts barrel that
reassembles <lang>Translation in the original key order.

Mechanical, script-driven split with a JSON round-trip check: every
locale object is byte-identical to its pre-split form. i18n.ts is
unchanged — './locales/<lang>' now resolves to the folder index.

The per-namespace settings.ts files land ~440-460 LOC; a single i18n
namespace is the natural, non-arbitrary split unit for a flat string
table, so they are intentionally left whole.
2026-05-14 14:06:31 +02:00
Frank Stellmacher ec112922a2 refactor(hot-cache): extract pure helpers + analysis-prune cluster from hotCachePrefetch.ts (#687)
The prefetch worker / replan orchestration and its shared module state
(pendingQueue, workerRunning, debounceTimer, graceEvictTimer) stay
entirely untouched in hotCachePrefetch.ts. Extracted only what does not
touch that state:

- hotCachePrefetch/helpers.ts       pure helpers — entryKey, byte
                                    estimators, debounceMs, frontend
                                    debug log, PREFETCH_AHEAD, PrefetchJob
- hotCachePrefetch/analysisPrune.ts  self-contained analysis-queue prune
                                    cluster (its own analysisPruneTimer +
                                    lastAnalysisPruneSig state, fully
                                    encapsulated behind a
                                    resetAnalysisPruneState wrapper)

Verbatim moves. The orchestration core diff is import-block + the three
analysis-cleanup lines collapsing to resetAnalysisPruneState() — nothing
else. The MainApp call site imports initHotCachePrefetch unchanged.
2026-05-14 13:40:21 +02:00
Frank Stellmacher f419081a2a refactor(lucky-mix): extract pure helpers into luckyMixHelpers.ts (#686)
Move the sampling / dedup / pool-fetching helpers and their constants out
of luckyMix.ts into luckyMixHelpers.ts:

- sampleRandom, uniqueBySongId, uniqueAppend
- deriveTopArtistsFromFrequentAlbums, fetchFrequentAlbumsPool
- pickSongsForArtist, pickSongsForAlbum, pickGoodRatedSongs
- TopArtist + the MOST_PLAYED / MIX / SEED size constants

luckyMix.ts keeps the buildAndPlayLuckyMix orchestration. Verbatim
code-move. The LuckyMix page imports buildAndPlayLuckyMix unchanged.
2026-05-14 13:29: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 8c4309cc8e refactor(image-cache): split imageCache.ts into focused submodules (#684)
imageCache.ts keeps the orchestration entry points + re-exports; the
internals move into imageCache/ with an acyclic dependency graph:

- constants.ts          DB / cache size knobs
- blobCache.ts          in-memory LRU blob map + inflight reads
- urlPool.ts            refcounted shared object URLs
- netFetchScheduler.ts  priority-ordered network fetch slots
- idbStore.ts           IndexedDB open / read / write / evict
- coverSiblings.ts      cover-size sibling probing + upgrade race

Verbatim function-body moves. The only non-move changes are three
named wrappers (cancelScheduledEvict, clearAllUrlEntries, clearCoverState)
that let clearImageCache reach cross-module state — each is the original
lines wrapped in a function, behaviour identical. All seven importers
import from utils/imageCache unchanged.
2026-05-14 13:12:43 +02:00
Frank Stellmacher 7e99f030a7 refactor(shortcuts): split shortcutActions.ts into focused modules (#683)
shortcutActions.ts becomes a barrel; the subsystem splits into:

- shortcutTypes.ts            shared types
- shortcutActionRegistry.ts   SHORTCUT_ACTION_REGISTRY + action id types
- shortcutDispatch.ts         runtime + CLI command dispatch
- shortcutBindings.ts         derived in-app / global binding tables

Pure code-move, no behaviour change. All call sites import from
config/shortcutActions unchanged.
2026-05-14 12:51:10 +02:00
Frank Stellmacher 1dd74f0fa1 refactor(tauri-bridge): split TauriEventBridge into per-concern hooks (#682)
Each Rust<->React bridge concern moves into its own hook under
hooks/tauriBridge/; TauriEventBridge just composes them:

- useZipDownloadBridge        download:zip:progress
- usePreviewBridge            audio:preview-* lifecycle
- useAudioDeviceBridge        audio:device-changed / -reset
- useCliBridge                full cli:* listener surface
- useTrayIconSync             tray-icon visibility
- useInAppKeybindings         configurable keydown chords
- useMediaAndWindowBridge     media keys, tray, shortcuts, close/force-quit
- usePlayerSnapshotPublisher  psysonic --info JSON snapshot

Pure code-move — event names, payloads, effect deps and cleanup all
verbatim. The MainApp call site is unchanged.
2026-05-14 12:31:14 +02:00