Commit Graph

1131 Commits

Author SHA1 Message Date
Frank Stellmacher 6595c146a3 feat(album): show OpenSubsonic disc subtitles after the CD heading (#753)
* feat(album): show OpenSubsonic disc subtitles after the CD heading

Multi-disc albums in OpenSubsonic / Navidrome carry a per-disc
subtitle (`discTitles`) — e.g. "Sessions" on CD 3 of a deluxe
edition. AlbumTrackList only rendered "CD N" and dropped the
subtitle, so users couldn't tell two discs apart unless they read
the track names.

* `SubsonicAlbum.discTitles` typed; `getAlbum` forwards it as-is.
* `AlbumTrackList` and `AlbumTrackListMobile` take a discTitleByNum
  map and render the subtitle in the disc separator after "CD N".
* Heading bumped slightly (13 → 15 px, icon 16 → 18 px) so the disc
  separator stays legible next to the new subtitle.

* docs(changelog): note disc subtitles after CD heading (#753)
2026-05-17 15:29:29 +02:00
Frank Stellmacher 48c7b8b780 fix(playerbar): add Mute / Unmute tooltip on the speaker icon (#752)
The speaker button in the player bar toggled volume to 0 (or back to
the pre-mute level) but exposed no `data-tooltip`, so hovering it
showed nothing — users couldn't tell what the icon did. Add a tooltip
that switches between "Mute" and "Unmute" with the current state, and
mirror the same string on `aria-label` for screen readers. New
`player.mute` / `player.unmute` keys land across all 9 locales.
2026-05-17 14:26:19 +02:00
Frank Stellmacher 04149c048e fix(settings): server row actions wrap inside the card at narrow widths (#751)
The action cluster (Test Connection / Use / Delete) had `flex-shrink: 0`
and the parent flex row had no `flex-wrap`, so on narrower Settings
panes the buttons spilled past the card's right edge instead of
flowing under the server info. Enable `flex-wrap: wrap` on the parent
and pin the actions with `margin-left: auto` so they stay right-aligned
when they wrap to a new line.
2026-05-17 14:15:48 +02:00
Frank Stellmacher 5856bdbf1a fix(library): empty-state on Mainstage, Albums, New Releases, Random Albums (#750)
* fix(library): show empty-state on Mainstage, Albums, New Releases, Random Albums

When the active library has no albums, Mainstage and the three album-list
pages rendered a fully blank page — every rail and grid was empty but
nothing told the user why. Add a shared `common.libraryEmpty` message
(all 9 locales) and render it in place of the empty grid:

* Home: when no rail has any albums after loading.
* Albums: when no albums and no filter (genre/year/starred/comp) is
  active — filtered "no matches" is a separate state and stays as-is.
* New Releases: when no albums and no genre filter is active.
* Random Albums: when no albums after loading.

Pages that already had a dedicated empty-state (Artists, Genres,
Composers, Playlists, Favorites, MostPlayed, LosslessAlbums,
LabelAlbums, InternetRadio) are left alone — their wording is
intentional and per-page.

* docs(changelog): note empty-library states on Mainstage / album lists (#750)
2026-05-17 14:10:36 +02:00
Frank Stellmacher 9609da6bc2 fix(title): use ⏵ instead of ▶ in OS window title so glyph stays centred (#749)
`▶` (U+25B6, Geometric-Shapes) renders well below the baseline in most
OS title-bar fonts (Segoe UI on Windows, Cantarell on GNOME, etc.),
while `⏸` (U+23F8, Miscellaneous-Technical) sits correctly centred.
Switching the play glyph to `⏵` (U+23F5) — the designated `⏸` pair from
the same Unicode block — keeps the two states visually aligned with
the surrounding text. In-app custom title bar already looks correct
and is left untouched.
2026-05-17 13:37:22 +02:00
Frank Stellmacher ebba3dfa09 fix(fullscreen): preflight artist portrait URL so broken-img glyph never shows (#748)
Navidrome / Subsonic `getArtistInfo2` often returns a `largeImageUrl`
that 404s when the artist has no scraped image. The hook returned the
URL as-is, the FullscreenPlayer's `artistBgUrl || coverUrl` chain saw
a non-empty string and skipped the cover-art fallback, and FsPortrait
rendered an `<img>` whose `src` failed — leaving the browser's default
broken-image glyph in the middle of the screen.

Preflight the URL via an `Image()` probe and only expose it once it
actually loads; on error leave the hook's state empty so the cover-art
fallback in the caller takes effect.
2026-05-17 13:20:50 +02:00
Frank Stellmacher 4916c4a36f fix(equalizer): redraw curve when <details> reopens (#747)
* fix(equalizer): redraw curve when surrounding <details> toggles open

ResizeObserver does not reliably fire for the `display:none → block`
transition that the SettingsSubSection `<details>` causes when the user
expands the Equalizer panel after collapsing it. drawCurve bails on a
zero-sized canvas (existing macOS-WebKit guard), and without a new
trigger the second open leaves the frequency-response canvas blank.
Add a `toggle` listener on the closest `<details>` ancestor; on open,
redraw after one rAF so the canvas has its laid-out size. Additive —
doesn't change the working ResizeObserver path.

* docs(changelog): note Equalizer canvas redraw fix (#747)
2026-05-17 13:11:01 +02:00
Frank Stellmacher 2beb30f871 fix(favorites): artist link no-play + inline bulk action chips (#746)
* fix(favorites): artist link in songs table no longer triggers playback

The artist cell's click handler navigated without `stopPropagation`, so
the row's onClick still fired and started the track. Matches the album
cell's behaviour and the other tracklists (SongRow, PlaylistTracklist,
PlaylistSuggestions, SongCard, AlbumCard) — all of which already guard
the navigation click.

* fix(favorites): inline bulk action chips in section header

The full-width bulk-action bar above the column header pushed every row
down by ~36 px when an item was selected, making it harder to pick
adjacent items. Move the "X selected / Add to playlist / Clear" cluster
into the existing action-buttons row (right-aligned via margin-left:
auto), matching the album toolbar pattern. Selection mode no longer
shifts the rows. Clear-selection button uses `btn-surface` to match the
other secondary actions on the page (post-#745 convention).

* docs(changelog): note Favorites artist-link + bulk-bar fixes (#746)
2026-05-17 12:55:31 +02:00
Frank Stellmacher 3b94368ffa fix(ui): visual consistency sweep — shapes, buttons, hero, header alignment (#745)
* fix(ui): square shape across badges, pills and non-player buttons

zunoz on Discord flagged inconsistent shapes for play buttons, badges
and pills across the app. Unify to var(--radius-sm) for non-player
surfaces so the same visual indicator looks identical wherever it
appears. Player Bar, Fullscreen Player and Mini Player keep their
circular shape as part of the player family; toggle switches, sliders,
search input, pagination dots and theme overrides are left alone.

Covers: hero play + nav arrows, album-card details button, album
header icon buttons, playlist suggestion play, album-row nav arrows;
.badge (incl. New / album-detail), genre pill, np chip/tag/badge,
np-dash toolbar badge, radio filter chip, radio card chip, mp album
plays pill, settings search-result badge, alphabet filter buttons,
download hint, mobile search chip, artist release-group count, artist
external link, all Orbit session pills, device-sync count badge;
ServersTab "Aktiv" badge, PlaylistCard loading badge, AlbumRow /
ArtistRow "more" buttons.

* fix(composers): collapse empty space between virtual rows

The composer grid uses text-only tiles (~78 px intrinsic) but
estimateRowHeightPx scaled with cell width like the image variants,
clamped to a 200 px maximum. On normal viewports every virtual row
reserved ~200 px while the actual card was ~78 px, leaving ~120 px of
empty space below each row.

Pin the composer variant to min === max so the rowHeight is a fixed
88 px regardless of cell width.

* fix(ui): unify secondary action buttons on btn-surface

Action rows on the Artist, Album, Tracks, Favorites and Most Played
pages mixed btn-ghost (borderless), btn-surface (bordered) and bare
.btn (no variant, picked up bordered look in light themes only).
Result was per-page and per-theme inconsistency — secondary buttons
sometimes had borders, sometimes not.

Unify on btn-surface for all secondary actions so the same affordance
looks identical across pages and the difference between themes is just
border tone, not border presence.

- Tracks hero: Enqueue and Reroll buttons
- AlbumHeader: Shuffle, Enqueue, Star, Share, Bio, Download and the
  Offline-cache states
- MostPlayed: sort toggle and compilations filter (now matches the
  Albums page header)

* fix(hero): make pagination dots visible on light backdrops

The pagination dots used `rgba(255, 255, 255, 0.35)` which disappears
against white-dominant cover art and on light themes, even under the
hero gradient overlay. zunoz on Discord reported the inactive dots as
effectively invisible.

Bump inactive dots to 85 % white with a dark outline + drop shadow so
they read against any backdrop, and switch the active dot to the
accent color so the highlight reads as colour, not just width.

Also opaque-fill `.badge` (`var(--accent)` + `--ctp-crust` text) so the
hero pills do not disappear against light cover art either — base
class previously used `--accent-dim` which is too transparent for
badges per the existing badge rule.

* fix(tracks): align Browse-all-tracks header with rows

The Tracks page header sat outside the scroll container while rows
sat inside it, so the scrollbar gutter shrank only the rows and the
last header column drifted right of its row data. With OpenDyslexic
selected the wider glyphs pushed "Duration" past the viewport edge
entirely; zunoz on Discord reported it.

Move SongListHeader inside the scroll container with position:
sticky so header and rows share the same width budget. Sticky
keeps the header visible while scrolling as a side benefit.

* test(cardGridLayout): pin composer variant to fixed row height

Guards the fix that collapsed the empty space between Composers grid
rows. Re-introducing the `cellWidthPx + extra` scaling for composer
would silently bring back ~120 px of dead space per virtual row, so
the test asserts the fixed 88 px output across a wide cellWidth range.

Image variants (artist / album / playlist) get baseline assertions so
the composer case is documented as the deliberate exception, not an
oversight.

* docs(changelog): UI consistency sweep (PR #745)
2026-05-17 12:32:43 +02:00
Frank Stellmacher 4214a20ded fix(settings): stabilize header height, raise search affordance (#744)
The search toggle in Settings shifted everything 1-2 px down when
opened because the open input wrap was taller than the closed icon
button, and the header row sized to its children. Lock the header to
a min-height of 40 px so both states fit, give the icon button a
proper 28x28 hit area with hover state, and pin the open input wrap
to 32 px so toggling never reflows the row.
2026-05-17 11:26:55 +02:00
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 97957df310 fix(build): enable zbus async-io feature on linux (#738)
Without async-io (or tokio), zbus 5.15 with default-features = false
fails to compile (`Either "async-io" (default) or "tokio" must be enabled`),
which in turn broke `psysonic-audio` and the root `psysonic` crate on
clean rebuilds. Workspace `cargo --workspace` builds happened to succeed
because feature unification masked the gap; standalone clean builds did not.
2026-05-16 23:17:48 +03: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
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