Commit Graph

549 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
Frank Stellmacher 02e23b5755 fix(home): align mainstage row title with "New Releases" (#741)
* fix(home): mainstage row matches "New Releases" sidebar + page label

The Mainstage row whose title chevron links to `/new-releases` was
labelled **Recently Added** (`home.recent`) while the sidebar entry and
the page itself are **New Releases** (`sidebar.newReleases`) — three
labels for the same destination. Reported on the Psysonic Discord.

Reuse `sidebar.newReleases` in both consumers (the row title in
`Home.tsx` and the section label in `HomeCustomizer.tsx`) so the
string lives in exactly one place. The now-orphaned `home.recent` key
is dropped from all nine locale files.

* docs(changelog): mainstage New Releases label fix (PR #741)
2026-05-17 01:06:23 +02:00
Frank Stellmacher fcae65db80 fix(stats-export): full-resolution preview, fit Square in modal (#740)
* fix(stats-export): full-resolution preview, fit Square in modal

Reported on the Psysonic Discord — three connected issues with the
**Share Top Albums** dialog on the Statistics page:

1. **Square preview clipped.** `PreviewFrame` capped only `maxHeight: 52vh`
   while letting `width: 100%` + `aspectRatio: 1/1` push the 1:1 canvas
   far past the cap; `overflow: hidden` then chopped the bottom rows
   with no way to scroll them in (Twitter is width-bound, Story is
   width-capped at 320px, so Square was the only ratio that overflowed).
   Cap **both** dimensions per format — Square gets `maxWidth: 52vh`,
   Story gets `min(320px, calc(52vh * 9/16))`, Twitter stays
   width-bound by the modal — so the preview always fits.

2. **Outer scrolling didn't reveal the bottom.** Same root cause: the
   modal-content scroll only exposed the action buttons; the canvas
   itself sat inside `overflow: hidden` with nothing more to scroll to.
   Fixed implicitly by (1).

3. **Preview is blurry.** `PREVIEW_MAX_WIDTH = 540` rendered a 540×540
   canvas that CSS then stretched back to ~676px in a 720px-wide modal,
   and `desiredTilePx = 256` decoded covers at 256 px only to upscale
   them into ~300 px tiles. Render the preview canvas at the full
   export width (1080) and decode covers at the export tile size (600)
   so text is sharp and covers downsample crisply.

* docs(changelog): stats-export preview fix (PR #740)
2026-05-17 00:55:08 +02:00
Frank Stellmacher 6cc227d761 fix(artist-info): id-gate fetchers, share ArtistCard, square hero (#739)
* fix(artist-info): id-gate fetcher tuples, reuse ArtistCard on ArtistDetail

The cache-mismatch bug PR #732 fixed in `NowPlayingInfo.tsx` had the same
shape inside `ArtistCard` on the NowPlaying page: `useNowPlayingFetchers`
returned `artistInfo` for the previously-current artist for one render
after `artistId` changed, and `CachedImage` persisted that mismatched
blob under the new `artistInfo:<new-id>:hero` key in IndexedDB —
sticky "previous artist" image on every subsequent track.

Apply the same `{ id, value }` tuple pattern from PR #732 inside the
hooks themselves so every consumer is safe by construction:

- `useNowPlayingFetchers`: gate `artistInfo`, `songMeta`, `albumData`,
  `discography` on id-match at the return. Late-arriving resolves for
  a stale id can no longer overwrite the displayed value.
- `useArtistDetailData`: same for `info`. Required because the
  `ArtistDetail` bio card now uses `CachedImage` via the shared
  `ArtistCard` (previously raw `<img>`, no persistence hazard).

Unify `ArtistDetail`'s inline "About the Artist" block onto the same
shared `ArtistCard` so there is one source of truth for hero / bio /
similar rendering. New optional props: `onNavigate?` (omitted on
`/artist/:id` since the user is already there), `coverFallback`
(coverArt fallback when artistInfo has no hero image),
`hideArtistName` (avoid duplicating the hero name), `hideSimilar`
(ArtistDetail has its own similar-artists section).

Tests cover the gating contract for both hooks (incl. stale-resolve
race) and the `ArtistCard` prop matrix.

* fix(artist-image): square queue info hero, drop artist-avatar glow

- Queue Info bar's artist hero was rendered in a 16:10 wrap with
  `object-fit: cover`, so portrait photos lost top/bottom equally
  while landscape ones lost the sides — perceived as cropped even
  on roughly square sources. Set the wrap to 1:1 so the crop is
  symmetric and matches the typical square framing of artist
  photography.

- `ArtistDetail` extracted the cover's accent colour on every image
  load and rendered a 36px / 8px-spread `boxShadow` ring around the
  avatar. Drop the glow, the state, the one-shot reset effect, the
  prop-passing through `ArtistDetailHero`, and the now-orphaned
  `extractCoverColors` import on this page. `extractCoverColors`
  itself stays in place (still used by `useFsDynamicAccent`).

* docs(changelog): artist-info image fix extension + UI tweaks (PR #739)
2026-05-17 00:43:05 +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
Frank Stellmacher 1ac354fb67 fix(favorites): show artist name in filter label, not Subsonic ID (#736)
Clicking a card under Top Artists by Favorites set the artist filter
to the artist's Subsonic ID, and the "Showing X of Y" label
interpolated that ID into the `{{artist}}` placeholder — so the user
saw a GUID like "OjdsOiMQ6ve5rZWPj2ePFc" instead of "Toto".

Look the name up from `topFavoriteArtists` (already on the page,
each entry carries `{id, name}`), and pass it to the header. The ID
filter itself is unchanged — the song-filtering hook still matches on
`artistId` / `artist` / `albumArtist`.

Reported by zunoz on Discord.
2026-05-16 13:51:11 +02:00
Frank Stellmacher 31abdc03ef feat(hero): prev / next arrows on the Mainstage featured strip (#735)
* feat(hero): prev / next arrows on the Mainstage featured strip

Adds left and right chevron buttons over the featured-album hero so a
single click flips to the previous / next album. Hitting the 8 px dot
indicators was awkward and often opened the underlying album by
mistake; the arrows give a generous 44 px touch target on each edge.

- New `goPrev` / `goNext` callbacks wrap with modulo, restart the
  auto-advance timer on click (same pattern the old dot handler used).
- Buttons live in a new `.hero-nav` flex wrapper with `inset: 0` and
  `justify-content: space-between`, so they pin to the hero's left and
  right edges regardless of theme padding. `pointer-events: none` on
  the wrapper + `auto` on the buttons keeps the rest of the hero
  click-through (navigate to album).
- Dots are now decorative spans, not buttons — `pointer-events: none`,
  no hover state, no `onClick`. Clicking near a dot used to navigate
  to the album because the dot was too small to land on.
- i18n: `previousAlbum` / `nextAlbum` keys in all 9 locales.

Reported by zunoz on Discord.

* docs(changelog): note Mainstage hero prev / next arrows (#735)
2026-05-16 13:43:09 +02:00
Frank Stellmacher 2233e8fb91 fix(album): contain Artist Biography modal scroll within frame (#734)
* fix(album): contain Artist Biography modal scroll within frame

The modal was rendered inside the album page tree, where an ancestor
broke `position: fixed` so the overlay scrolled with the page instead
of pinning to the viewport. Long bios also pushed the modal past the
viewport entirely.

- Render `BioModal` via `createPortal(document.body)` so no ancestor's
  transform/filter/backdrop-filter can break fixed positioning.
- New `.modal-content.bio-modal` variant: flex column, `overflow:
  hidden`. Title + close button stay pinned, only `.bio-modal-body`
  scrolls. The existing `max-height: 80vh` on `.modal-content` now
  reliably caps the modal to the visible viewport.

Reported by zunoz on Discord.

* docs(changelog): note Artist Biography modal scroll fix (#734)
2026-05-16 13:22:59 +02:00
Frank Stellmacher 7f9b5bd57d fix(album): hide Artist Bio button on Various-Artists compilations (#733)
* fix(album): hide Artist Bio button on Various-Artists compilations

The Album header showed an Artist Bio button on every album, but
when the album artist label is "Various Artists" / "Various" / "VA"
or a language equivalent there is no single artist to fetch a bio
for — the button opened an empty modal. Hide both the mobile icon
and the desktop button when the label matches that heuristic.

* docs(changelog): album bio button hidden for compilations (PR #733)
2026-05-16 03:01:12 +02:00
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
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
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 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
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 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
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
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 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
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 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 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 b89b5d7c94 refactor(app-updater): split AppUpdater.tsx into focused modules (#681)
Extract the GitHub release probe, download/relaunch state and the markdown
changelog renderer out of AppUpdater.tsx:

- utils/appUpdaterHelpers.ts   isNewer, fmtBytes, pickAsset, types
- hooks/useAppUpdater.ts        release probe + download/relaunch handlers
- components/appUpdater/Changelog.tsx

Pure code-move, no behaviour change. The AppShell call site is unchanged.
2026-05-14 12:12:16 +02:00
Frank Stellmacher cca000d3af refactor(equalizer): split Equalizer.tsx 508 → 166 LOC across 6 files (#680)
Extract the canvas frequency-response math, the custom vertical fader, the
AutoEQ parser, and the AutoEQ search panel out of Equalizer.tsx:

- utils/eqCurve.ts          biquadPeakResponse + drawCurve
- utils/autoEqParse.ts      AutoEq types + parseFixedBandEqString
- components/equalizer/VerticalFader.tsx
- hooks/useAutoEq.ts        AutoEQ search/apply state
- components/equalizer/AutoEqSection.tsx

Pure code-move, no behaviour change. Both call sites (AudioTab, PlayerBar)
default-import Equalizer unchanged.
2026-05-14 11:57:12 +02:00
Frank Stellmacher ff99b10faa refactor(album-detail): I.7 — split AlbumDetail.tsx 511 → 369 LOC across 5 files (#679)
* refactor(album-detail): extract sanitizeFilename + useAlbumDetailData

Move sanitizeFilename into utils/albumDetailHelpers.ts. Pull the album +
related-albums fetch and the starred state seeds (isStarred,
starredSongs) into hooks/useAlbumDetailData.ts.

AlbumDetail.tsx: 511 → 483 LOC.

* refactor(album-detail): extract useAlbumOfflineState hook

Move the four primitive-selector offline status reads (cache map +
job-status filters + progress totals) into hooks/useAlbumOfflineState.ts.
Keeps the re-render minimisation comment with the code that needs it.

AlbumDetail.tsx: 483 → 461 LOC.

* refactor(album-detail): extract useAlbumDetailSort hook

Pull sortKey/Dir/clickCount state, the 3-click natural-reset cycle in
handleSort, and the displayedSongs memo (filter + sort comparator) into
hooks/useAlbumDetailSort.ts. Rating comparator keeps the same priority
chain as the row renderer.

AlbumDetail.tsx: 461 → 414 LOC.

* refactor(album-detail): extract AlbumDetailToolbar subcomponent

Pull the search input + bulk-action cluster (selection count, add-to-
playlist popover, clear-selection button) into
components/albumDetail/AlbumDetailToolbar.tsx. Parent retains showPlPicker
to coordinate the popover close with selection clears.

AlbumDetail.tsx: 414 → 369 LOC.
2026-05-14 01:19:58 +02:00
Frank Stellmacher 4dc46e176a refactor(user-mgmt): I.5 — split UserManagementSection.tsx 515 → 153 LOC across 6 files (#677)
* refactor(user-mgmt): extract formatLastSeen helper

Move the relative-time formatter (with the Navidrome
'0001-01-01T00:00:00Z' epoch guard) into utils/userMgmtHelpers.ts.

UserManagementSection.tsx: 515 → 499 LOC.

* refactor(user-mgmt): extract useUserMgmtData hook

Pull users + libraries state, sequential admin-API fetch, and the
nginx-friendly error normalisation into hooks/useUserMgmtData.ts.

UserManagementSection.tsx: 499 → 464 LOC.

* refactor(user-mgmt): extract useUserMgmtActions hook

Bundle handleSave (covers create + edit + library assignment),
handleSaveAndGetMagic (new non-admin user → encoded magic string on
clipboard), and performDelete into hooks/useUserMgmtActions.ts. The
delete confirmation modal now closes inline in the parent before
delegating to performDelete so the hook stays agnostic of UI state.

UserManagementSection.tsx: 464 → 343 LOC.

* refactor(user-mgmt): extract UserMgmtRow subcomponent

Move the per-user list row (user/admin badges, lib-names blob, magic-
string + delete actions, keyboard activation) into
components/settings/userMgmt/UserMgmtRow.tsx.

UserManagementSection.tsx: 343 → 272 LOC.

* refactor(user-mgmt): extract MagicStringModal subcomponent

Move the per-user magic-string portal modal (password re-set + clipboard
copy of the encoded server-magic-string) into
components/settings/userMgmt/MagicStringModal.tsx. Internal password and
submitting state move into the modal; the parent only owns which user is
targeted.

UserManagementSection.tsx: 272 → 153 LOC.
2026-05-14 00:45:48 +02:00
Frank Stellmacher 6552d2a5cb refactor(artists): I.4 — split Artists.tsx 520 → 233 LOC across 6 files (#676)
* refactor(artists): extract helpers + constants

Pull ALL_SENTINEL / ALPHABET / ARTIST_LIST_* row-height estimates,
the ArtistListFlatRow union, CTP_COLORS palette, and the deterministic
nameColor / nameInitial helpers into utils/artistsHelpers.ts.

Artists.tsx: 520 → 496 LOC.

* refactor(artists): extract ArtistAvatars subcomponents

Pull ArtistCardAvatar (300px, grid view) and ArtistRowAvatar (64px, list
view) into components/artists/ArtistAvatars.tsx. Both fall back to a
hashed-Catppuccin monogram when artist images are off or no cover art
is available.

Artists.tsx: 496 → 436 LOC.

* refactor(artists): extract useArtistsFiltering hook

Bundle the letter/text/star filter pipeline, visible-slice memo,
group-by-letter, and the virtualizer flat-rows list into
hooks/useArtistsFiltering.ts. List-view-only outputs short-circuit when
grid view is active.

Artists.tsx: 436 → 386 LOC.

* refactor(artists): extract useArtistsInfiniteScroll hook

Bundle visibleCount + loadingMore state, the sentinel
IntersectionObserver, loadMore callback, and the filter-change reset
into hooks/useArtistsInfiniteScroll.ts. The observer no longer takes
hasMore — the sentinel element only mounts while there is more data,
so the observer attaches/detaches naturally with it.

Artists.tsx: 386 → 370 LOC.

* refactor(artists): extract ArtistsGridView + ArtistsListView

Move the grid card layout to components/artists/ArtistsGridView.tsx and
the dual-path list layout (non-virtualized fallback + virtualized stream)
to components/artists/ArtistsListView.tsx. Both paths now share an
internal ArtistListRow component so click + context-menu behaviour is
identical regardless of which renderer is active.

Artists.tsx: 370 → 233 LOC.
2026-05-14 00:33:27 +02:00
Frank Stellmacher 59772db5ee refactor(audio-tab): I.3 — split AudioTab.tsx 521 → 97 LOC across 6 files (#674)
* refactor(audio-tab): extract useAudioDevicesProbe hook

Pull the device-list state, refreshAudioDevices callback, mount probe,
and the audio:device-changed / audio:device-reset listener wiring into
hooks/useAudioDevicesProbe.ts. macOS short-circuit lives in the hook.

AudioTab.tsx: 521 → 463 LOC.

* refactor(audio-tab): extract AudioOutputDeviceSection

Pull the audio output device picker (macOS notice + CustomSelect +
refresh button) into components/settings/audio/AudioOutputDeviceSection.tsx.

AudioTab.tsx: 463 → 418 LOC.

* refactor(audio-tab): extract NormalizationBlock

Pull the engine picker (Off / ReplayGain / LUFS) and the engine-specific
config blocks (RG mode + pre-gain + fallback; LUFS target + pre-analysis
attenuation with reset) into components/settings/audio/NormalizationBlock.tsx.

AudioTab.tsx: 418 → 267 LOC.

* refactor(audio-tab): extract PlaybackBehaviorBlock

Pull Crossfade ↔ Gapless mutually-exclusive toggles + Preserve Play Next
Order into components/settings/audio/PlaybackBehaviorBlock.tsx. The
crossfade-seconds slider only renders while crossfade is the active mode.

AudioTab.tsx: 267 → 201 LOC.

* refactor(audio-tab): extract TrackPreviewsSection

Pull the track previews subsection (master toggle, per-location grid,
start-ratio slider, duration slider) into
components/settings/audio/TrackPreviewsSection.tsx.

AudioTab.tsx: 201 → 97 LOC.
2026-05-14 00:20:01 +02:00
Frank Stellmacher f14c8f21e6 refactor(album-track-list): I.2 — split AlbumTrackList.tsx 662 → 187 LOC across 7 files (#672)
* refactor(album-track-list): extract helpers + types

Pull formatDuration / codecLabel, the COLUMNS / CENTERED_COLS / SORTABLE_COLS
tables, ColKey / SortKey types, and the isSortable type guard into
utils/albumTrackListHelpers.ts. SortKey is re-exported from
AlbumTrackList.tsx so existing imports stay valid.

AlbumTrackList.tsx: 662 → 633 LOC.

* refactor(album-track-list): extract TrackRow subcomponent

Move the memoised tracklist row (~220 LOC including renderCell switch and
mouse handlers) into components/albumTrackList/TrackRow.tsx. It still
subscribes to its own selection + preview state via primitive selectors,
so per-row re-render scope is unchanged.

AlbumTrackList.tsx: 633 → 404 LOC.

* refactor(album-track-list): extract AlbumTrackListMobile subcomponent

Move the narrow-viewport branch (compact tracklist with disc separators
and no column grid) into components/albumTrackList/AlbumTrackListMobile.tsx.

AlbumTrackList.tsx: 404 → 376 LOC.

* refactor(album-track-list): extract TracklistColumnPicker subcomponent

The column visibility dropdown lives outside .tracklist to avoid the
overflow box clipping its menu — pull the wrapper + button + popover into
components/albumTrackList/TracklistColumnPicker.tsx.

AlbumTrackList.tsx: 376 → 347 LOC.

* refactor(album-track-list): extract TracklistHeaderRow subcomponent

The fixed header (sortable + resizable per-column with the bulk-select
toggle on the num cell) moves into
components/albumTrackList/TracklistHeaderRow.tsx, taking 85+ LOC of cell
rendering with it.

AlbumTrackList.tsx: 347 → 254 LOC.

* refactor(album-track-list): extract useAlbumTrackListSelection hook

Pull bulk-selection state (selectedIds-size subscription, shift-range
toggle, click-outside-clear, song-list-change clear) and the drag-start
dispatcher (single vs multi-song drag) into
hooks/useAlbumTrackListSelection.ts.

AlbumTrackList.tsx: 254 → 187 LOC.
2026-05-14 00:01:36 +02:00
Frank Stellmacher b591a1cb5f refactor(app-shell): I.1 — split AppShell.tsx 691 → 248 LOC across 12 files (#671)
* refactor(app-shell): extract appShellHelpers.ts

Move SIDEBAR_COLLAPSED_STORAGE_KEY + read/persist helpers and the
shouldSuppressQueueResizerMouseDown geometry helper out of AppShell.tsx
into utils/appShellHelpers.ts.

AppShell.tsx: 691 → 639 LOC.

* refactor(app-shell): extract usePlatformShellSetup hook

Bundle the one-shot platform/window-shell effects (tiling-WM detection,
no-compositing class, data-platform attr, custom titlebar sync, kinetic
scroll toggle, logging mode push) into hooks/usePlatformShellSetup.ts.
Returns isTilingWm so AppShell can still gate the custom titlebar.

AppShell.tsx: 639 → 604 LOC.

* refactor(app-shell): extract 5 lifecycle hooks

Pull these effect islands into hooks/:
- useOrbitBodyAttrs       — orbit role/phase → <html data-orbit-*> attrs
- useWindowFullscreenState — Tauri isFullscreen() tracker
- useNowPlayingTrayTitle  — title + tray tooltip sync
- useTrayMenuI18n         — tray menu labels via i18n
- useServerCapabilitiesProbe — music folders + rating support + orbit
                              orphan sweep on login

AppShell.tsx: 604 → 497 LOC.

* refactor(app-shell): extract useQueueResizer hook

Bundle queueWidth state, drag listeners, sidebar-aligned handle position,
and the click-vs-drag mousedown handler into hooks/useQueueResizer.ts.
AppShell drops shouldSuppressQueueResizerMouseDown wiring and 4 local
state pieces.

AppShell.tsx: 497 → 403 LOC.

* refactor(app-shell): extract 4 misc lifecycle hooks

- useGlobalDndAndSelectionBlockers — document-level DnD/select-all/
  selectstart blockers (Linux/WebKitGTK + Wayland workarounds).
- useAppActivityTracking — <html data-app-hidden> + <html data-app-blurred>
  so CSS can pause cosmetic animation when the app isn't being looked at.
- useMainScrollingIndicator — scroll-idle tracker for main + np viewports.
- useOfflineAutoNav — connStatus transitions push to /offline or back.

AppShell.tsx: 403 → 282 LOC.

* refactor(app-shell): extract AppShellQueueResizerSeam subcomponent

The 6px resizer strip and the round resize/toggle handle (~50 LOC of JSX
with embedded scrollbar-collision suppression + self-heal logic) move
into components/AppShellQueueResizerSeam.tsx. Desktop-only.

AppShell.tsx: 282 → 248 LOC.
2026-05-13 23:48:03 +02:00
Frank Stellmacher 988806e6b1 refactor(player-bar): H7 — split PlayerBar.tsx 802 → 354 LOC across 10 files (#670)
* refactor(player-bar): H7 — extract PlaybackTime + RemainingTime + formatTime

The two memoized clock components (which update the DOM imperatively from
the playbackProgress store without re-rendering PlayerBar) move into
components/playerBar/PlaybackClock.tsx. formatTime helper → utils/playerBarHelpers.ts.

PlayerBar.tsx: 802 → 765 LOC.

* refactor(player-bar): H7 — extract PlayerTrackInfo

The cover-art wrap + title/artist marquees + star + last.fm love buttons
move into their own component. The new file uses PlayerState['openContextMenu']
for prop typing so the union literal type carries through.

PlayerBar.tsx: 765 → 674 LOC.

* refactor(player-bar): H7 — extract PlayerTransportControls

Stop/Prev/Play/Next/Repeat buttons (with the preview-ring + schedule-badge
overlays around play/pause) move into PlayerTransportControls.tsx. The
component uses ReturnType<...> on the source hooks to derive its
playPauseBind + scheduleRemaining prop types so the new file stays in lockstep
with usePlaybackDelayPress + usePlaybackScheduleRemaining.

PlayerBar.tsx: 674 → 621 LOC.

* refactor(player-bar): H7 — extract PlayerSeekbarSection

The waveform / radio progress / time-label block moves into its own
component. PlayerSeekbarSection branches on isRadio (AzuraCast progress
bar with elapsed+duration when available; LIVE badge otherwise) vs.
regular track (WaveformSeek or perf-flag fallback + duration ↔ remaining
toggle).

PlayerBar.tsx: 621 → 582 LOC.

* refactor(player-bar): H7 — extract PlayerVolume + PlayerOverflowMenu + 2 hooks

PlayerVolume.tsx is the reusable volume button + slider combo, used in
three layouts (inline, full menu, volume-only menu) — `inputId` /
`sectionModifier` / `wrapModifier` props handle the variants without
class duplication. PlayerOverflowMenu.tsx is the portaled Ellipsis-button
menu that hosts EQ / mini-player buttons + a PlayerVolume instance.

useFloatingPlayerBar owns the docked/floating layout computation
(ResizeObserver on sidebar + queue panel). useUtilityOverflowMenu owns
the overflow detection, menu open/mode state, close-on-outside-click /
Escape, position recompute on resize/scroll, and the wheel-menu timer.

PlayerBar.tsx: 582 → 354 LOC.
2026-05-13 23:24:57 +02:00
Frank Stellmacher 383bbbd75f refactor(mini-player): H6 — split MiniPlayer.tsx 820 → 218 LOC across 11 files (#669)
* refactor(mini-player): H6 — extract helpers + constants

Pure code-move: window-size constants, localStorage keys + read helpers,
toMini track shape, initialSnapshot, and fmt(seconds) → utils/miniPlayerHelpers.ts.

MiniPlayer.tsx: 820 → 745 LOC.

* refactor(mini-player): H6 — extract MiniTitlebar + MiniMeta + MiniControls

Three small visual subcomponents move into components/miniPlayer/.
MiniPlayer drops the now-unused Pin/PinOff/Maximize2/X/Play/Pause/
SkipBack/SkipForward lucide icons and the CachedImage import.

MiniPlayer.tsx: 745 → 665 LOC.

* refactor(mini-player): H6 — extract MiniToolbar + useMiniVolumePopover

The whole toolbar (volume button + portaled popover, shuffle, gapless/
crossfade/infinite, queue toggle) moves into MiniToolbar.tsx. The volume
popover open-state + ref/style positioning + outside-click/Escape close
move into the useMiniVolumePopover hook.

MiniPlayer.tsx: 665 → 492 LOC.

* refactor(mini-player): H6 — extract MiniQueue + useMiniQueueDrag

The OverlayScrollArea + queue.map block moves into MiniQueue.tsx. The
PsyDnD wiring (drop-inside emits mini:reorder, drop-outside emits
mini:remove, reorder math collapsing same-position + adjusting for shift)
moves into the useMiniQueueDrag hook.

MiniPlayer.tsx: 492 → 346 LOC.

* refactor(mini-player): H6 — extract useMiniSync + useMiniWindowSetup + useMiniKeyboardShortcuts

Three more hooks pull the remaining side-effect islands out of MiniPlayer:
  - useMiniSync owns mini:ready emit on mount + focus, plus the
    mini:sync / audio:progress / audio:ended listeners. The hidden-window
    visibility ref that gates progress now lives inside the hook.
  - useMiniWindowSetup bundles three small window-bound effects:
    Linux WebKitGTK smooth-scroll, the cold-start expanded-size restore
    when queueOpen=true, and always-on-top reapply on mount + focus.
  - useMiniKeyboardShortcuts moves the keyboard-shortcut bridge wiring.

MiniPlayer.tsx: 346 → 218 LOC.
2026-05-13 23:07:45 +02:00
Frank Stellmacher 463d3e0c5b refactor(fullscreen-player): H5 — split FullscreenPlayer.tsx 911 → 228 LOC across 11 files (#668)
* refactor(fullscreen-player): H5 — extract FsLyricsApple + FsLyricsRail + useWordLyricsSync

The two lyrics views become own files under components/fullscreenPlayer/.
Their identical word-sync imperative DOM-update useEffect (only differing in
the .fsa-/.fsr- class prefix) collapses into the shared useWordLyricsSync
hook with a classPrefix arg.

FullscreenPlayer.tsx: 911 → 613 LOC.

* refactor(fullscreen-player): H5 — extract FsArt + FsPortrait + FsSeekbar

The three visual subcomponents move into own files. formatTime moves into
utils/fullscreenPlayerHelpers.ts so FsSeekbar keeps using it.

FullscreenPlayer.tsx: 613 → 421 LOC.

* refactor(fullscreen-player): H5 — extract FsLyricsMenu + FsPlayBtn

The lyrics-settings popover and the isolated play/pause button move into
own files. FullscreenPlayer drops the now-unused lucide-react icons
(Play/Pause/Moon/Sunrise/Music) and the PlaybackDelayModal +
PlaybackScheduleBadge imports — both used only by FsPlayBtn now.

FullscreenPlayer.tsx: 421 → 304 LOC.

* refactor(fullscreen-player): H5 — extract useFsDynamicAccent + useFsArtistPortrait + useFsIdleFade

Pulls three state+effect islands out of FullscreenPlayer:
  - useFsDynamicAccent owns the cover-blob fetch + extractCoverColors call
    plus the module-level artKey → accent cache that makes same-album song
    switches instant.
  - useFsArtistPortrait fetches getArtistInfo().largeImageUrl for the right-
    side portrait, returning '' until resolved (or when no artistId).
  - useFsIdleFade flips isIdle true after 3 s of inactivity, exposes a
    throttled mousemove handler, and binds Escape to the provided callback.

FullscreenPlayer.tsx: 304 → 228 LOC.
2026-05-13 22:52:08 +02:00
Frank Stellmacher 8ff630cb5c refactor(queue-panel): H4 — split QueuePanel.tsx 1256 → 383 LOC across 11 files (#667)
* refactor(queue-panel): H4 — extract helpers + Save/LoadPlaylistModal

Pure code-move: formatTime, formatQueueReplayGainParts, renderStars and the
DurationMode type → utils/queuePanelHelpers.tsx; the two playlist modals → own
files under components/queuePanel/.

QueuePanel.tsx: 1256 → 1104 LOC.

* refactor(queue-panel): H4 — extract QueueHeader

Pure code-move: the title/count/duration/collapse-button header → its own
component file. No prop or behaviour changes.

QueuePanel.tsx: 1104 → 1004 LOC.

* refactor(queue-panel): H4 — extract QueueCurrentTrack + QueueLufsTargetMenu

The currently-playing track block (cover, info, replay-gain / LUFS badge with
its target-listbox portal) moves into two own files. Pure code-move via prop
plumbing. setLoudnessTargetLufs is typed as LoudnessLufsPreset throughout the
new components.

QueuePanel.tsx: 1004 → 812 LOC.

* refactor(queue-panel): H4 — extract useQueuePanelDrag hook

Moves the psy-drag wiring (hit-test registration, drop-inside dispatch
for song/songs/album/queue_reorder payloads, drop-outside removal) into
its own hook. Drops the dead isRadioDrag variable since the
parsedData.type === 'radio' guard inside onPsyDrop already handles that
case.

QueuePanel.tsx: 812 → 715 LOC.

* refactor(queue-panel): H4 — extract useQueueLufsTgtPopover hook

Pulls the LUFS-target popover open-state, button/menu refs, fixed-position
recompute on open/resize/scroll, and auto-close-when-RG-collapses out of
QueuePanel.

QueuePanel.tsx: 715 → 662 LOC.

* refactor(queue-panel): H4 — extract QueueToolbar

The toolbar-button switch (shuffle/save/load/share/clear/gapless/crossfade/
infinite) and the crossfade popover (with its close-on-outside-click effect)
move into one component. crossfadeBtnRef / crossfadePopoverRef and
showCrossfadePopover state are now component-local — QueuePanel no longer
sees them.

QueuePanel.tsx: 662 → 541 LOC.

* refactor(queue-panel): H4 — extract QueueList

The OverlayScrollArea + queue.map block (with track rows, lucky-mix dice
overlay, and radio/auto-added section dividers) moves into its own
component. PlayerState['contextMenu'] + PlayerState['playTrack'] are
re-used for prop typing, the local StartDrag alias matches the
DragDropContext signature.

QueuePanel.tsx: 541 → 434 LOC.

* refactor(queue-panel): H4 — extract QueueTabBar + useQueueAutoScroll, final cleanup

QueueTabBar is the bottom queue/lyrics/info tab switcher. useQueueAutoScroll
groups the three list-scroll effects (publish scrollTop reader, restore
pending snapshot, scroll next track into view on advance). Drops the dead
toggleQueue and replayGainMode selectors plus the now-unused Play, Radio,
MicVocal, ListMusic, Info imports and OverlayScrollArea.

QueuePanel.tsx: 434 → 383 LOC. Every new file under 400.
2026-05-13 22:33:02 +02:00
cucadmuh 34cc311b4d docs(i18n): Romanian ro in 1.46.0 notes and README; chronological contributor credits (#666)
Settings System tab now follows CONTRIBUTORS array order instead of sorting by entry size.
2026-05-13 23:17:17 +03:00
Mihai Toderita 7a4bdbc88e Feat/romanian translation (#663)
* feat(i18n): Add Romanian translation

* feat(i18n): Update Romanian translation to lang file changes

* feat(i18n): Add new Romanian translation entries

* fix(i18n): add settings.languageRo to remaining locale bundles

Romanian was missing from the language picker labels when UI was not en/ro;
add endonym-style names per locale (de/fr/nl/nb/ru/es/zh) for consistency.

* fix(i18n): use Romanian autonym for settings.languageRo everywhere

Match existing language picker convention (e.g. languageDe is Deutsch in
every locale bundle). Replaces UI-language translations of Romanian.
2026-05-13 23:13:42 +03:00
Frank Stellmacher dc5c64a109 refactor(waveform-seek): H3 — extract renderers + 2 hooks + SeekbarPreview component (#665)
* refactor(waveform-seek): H3.1 — extract helpers + constants + types

* refactor(waveform-seek): H3.2 — extract drawSeekbar + style renderers to utils/waveformSeekRenderers.ts

* refactor(waveform-seek): H3.3 — split renderers into static + animated

* refactor(waveform-seek): H3.4 — extract SeekbarPreview to WaveformSeekPreview.tsx

* refactor(waveform-seek): H3.5 — extract useWaveformHeights hook + hoist constants

* refactor(waveform-seek): H3.6 — extract useWaveformInterpolation hook
2026-05-13 22:04:45 +02:00
Frank Stellmacher b8a9fe860e refactor(sidebar): H2 — extract 5 hooks + 5 sub-components (#664)
* refactor(sidebar): H2.1 — extract helpers + constants

* refactor(sidebar): H2.2 — extract useSidebarNewReleasesUnread hook

* refactor(sidebar): H2.3 — extract useSidebarNavDnd hook

* refactor(sidebar): H2.4 — extract 3 hooks (LibraryDropdown + ScrollVisible + PerfProbe)

* refactor(sidebar): H2.5 — extract SidebarPerfProbeModal component

* refactor(sidebar): H2.5–H2.8 — split JSX into per-block components

Sidebar.tsx 938 → 271 LOC. All resulting files under the 400-LOC guideline:
  components/sidebar/SidebarLibraryPicker.tsx       96
  components/sidebar/SidebarActiveJobs.tsx          58
  components/sidebar/SidebarNavBody.tsx            293
  components/sidebar/SidebarPerfProbeModal.tsx     259
  components/sidebar/SidebarPerfProbePhase2.tsx    176
2026-05-13 21:42:18 +02:00
Frank Stellmacher ef5eda263d refactor(context-menu): H.1–H.12 — extract submenus + 5 type-branch components + hooks (Phase H start) (#662)
* refactor(context-menu): H.1 — extract helpers + constants

* refactor(context-menu): H.2 — extract AddToPlaylistSubmenu component

* refactor(context-menu): H.3 — extract AlbumToPlaylistSubmenu + ArtistToPlaylistSubmenu

* refactor(context-menu): H.4 — extract MultiAlbumToPlaylistSubmenu

* refactor(context-menu): H.5 — extract MultiArtistToPlaylistSubmenu

* refactor(context-menu): H.6 — extract SinglePlaylist + MultiPlaylist submenus

* refactor(context-menu): H.7 — extract startRadio/startInstantMix/downloadAlbum/copyShareLink actions

* refactor(context-menu): H.8 — extract useContextMenuKeyboardNav hook

* refactor(context-menu): H.9 — extract useContextMenuRating hook

* refactor(context-menu): H.10 — extract ContextMenuItems (all 9 type branches)

* refactor(context-menu): H.11 — split ContextMenuItems into 5 type-branch files

ContextMenuItems.tsx (800 LOC) was just a moved 400-LOC-cap violation.
Now ContextMenuItems is a 30-LOC switch that dispatches to:
  - SongContextItems       (song + album-song + favorite-song)
  - QueueItemContextItems  (queue-item)
  - AlbumContextItems      (album + multi-album)
  - ArtistContextItems     (artist + multi-artist)
  - PlaylistContextItems   (playlist + multi-playlist)

All five branch files are now under 330 LOC; ContextMenu.tsx itself stays
at 194 LOC. Shared Props interface lives in contextMenuItemTypes.ts.

* refactor(context-menu): H.12 — strip unused imports from branch components
2026-05-13 21:06:49 +02:00
Frank Stellmacher 40dd0bd100 refactor(random-mix): G.88 — extract panels + dedupe track row (cluster, multi-commit) (#655)
* refactor(random-mix): G.88.1 — extract helpers + AUDIOBOOK_GENRES + filter logic

* refactor(random-mix): G.88.2 — extract RandomMixHeader component

* refactor(random-mix): G.88.3 — extract RandomMixFiltersPanel component

* refactor(random-mix): G.88.4 — extract RandomMixGenrePanel component

* refactor(random-mix): G.88.5 — extract RandomMixTrackRow (dedupe genre + main lists)
2026-05-13 18:21:50 +02:00
Frank Stellmacher d4d3b0e53f refactor(folder-browser): G.87 — extract column component + 3 hooks (cluster, multi-commit) (#654)
* refactor(folder-browser): G.87.1 — extract helpers + types

Move ColumnKind / NavPos / Column types + entryToAlbumIfPresent /
entryToTrack mappers + isFolderBrowserArrowKey /
folderBrowserHasKeyModifiers key-event helpers into
src/utils/folderBrowserHelpers.ts. Pure code move.

* refactor(folder-browser): G.87.2 — extract FolderBrowserColumn component

* refactor(folder-browser): G.87.3 — extract useFolderBrowserNowPlayingPath hook

* refactor(folder-browser): G.87.4 — extract useFolderBrowserScrolling hook

* refactor(folder-browser): G.87.5 — extract useFolderBrowserKeyboardNav hook
2026-05-13 18:06:47 +02:00