Commit Graph

364 Commits

Author SHA1 Message Date
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 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 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
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
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
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
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
cucadmuh 778c9e00fd fix(artists): restore infinite scroll after initial library load (#709)
* fix(artists): attach infinite-scroll observer when sentinel mounts

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: changelog and credits for radio edit modal portal (PR #699)
2026-05-14 22:26:44 +03:00
cucadmuh 5d53a63553 fix(search): hide search3 artists with zero albums (#697)
* fix(search): hide search3 artists with zero albums

* docs: changelog and credits for search zero-album filter (PR #697)
2026-05-14 22:17:26 +03:00
cucadmuh 3cc172723d fix(ui): split album and track artists (OpenSubsonic) (#696)
* fix(ui): split OpenSubsonic album and track artists in header and player

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

Fixes #552

* docs: changelog and credits for OpenSubsonic artist links (PR #696)
2026-05-14 21:56:39 +03:00
cucadmuh ecdbe0cf2a fix(player): stale cover blob and load state on track change (#695)
* fix(player): align cached cover URL with cacheKey on track change

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

* docs: changelog + credits for cover-art track-switch fix (PR #695)
2026-05-14 21:38:56 +03:00
Frank Stellmacher b4c8ed4b65 fix(offline): cancellable downloads + stable sidebar progress toast (#694)
* fix(sidebar): keep offline-download toast from squishing in a short window

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

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

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

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

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

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

Adds offlineJobStore cancellation tests.

* docs(changelog): offline download cancel button + toast sizing fixes
2026-05-14 20:08:08 +02:00
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
Frank Stellmacher 123fbcc802 fix(orbit): event-driven host push + guest seekbar lock (#537)
* fix(orbit): event-driven host push on play/pause flips

Without this, the worst-case delay between "host hits pause" and "guest
stops" is two full polling windows (host's 2.5 s push tick + guest's
2.5 s read tick, plus network) — long enough for the guest to noticeably
run past the host. Subscribing to playerStore.isPlaying changes adds at
most one extra remote write per flip; non-flip state ticks still ride
the existing 2.5 s timer. The listener filters on isPlaying so the
per-second currentTime ticks don't trigger spurious pushes.

* fix(orbit): lock seekbar for guests — sync follows the host

Guests could drag/click/wheel the seekbar, which would jump the local
player and then snap back at the next host poll (2.5 s of inconsistent
UX) — or push the guest into a diverged state where Catch Up was the
only way back. The seekbar is host-controlled in Orbit; the guest input
path now reflects that.

- App.tsx exposes `data-orbit-role="host"|"guest"` on the root element
  alongside the existing `data-orbit-active` marker.
- WaveformSeek's container gains a `.waveform-seek-container` class so
  CSS can target it.
- Guest rule: `pointer-events: none` on children blocks click / drag /
  wheel / hover; the parent keeps `cursor: not-allowed` + reduced opacity
  so the disabled state is visually unambiguous.

Hosts and non-orbit users see no change.

* docs(changelog): credit PR #537 (orbit sync latency + guest seekbar)
2026-05-11 13:34:02 +02:00
Kveld. 64b33e6941 feat: customizable queue toolbar with drag-and-drop reordering and visibility toggles (#534)
* Add drag-and-drop reordering and visibility toggles for queue toolbar

* docs(changelog): credit PR #534 (queue toolbar customization)

Adds the v1.46.0 CHANGELOG entry and a new bullet on kveld9's
contributors block in Settings → System.

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-11 12:35:38 +02:00
Psychotoxical 673d4ffe56 docs(changelog): add Cargo workspace refactor entry
Foundational Rust workspace split landed via #532. Crediting both
cucadmuh (M0–M7 structural work + analysis fixes) and Frank
(orbit batch + macOS gates + bonus PRs that rode along on the
branch).
2026-05-10 01:06:06 +02:00
Frank Stellmacher 25507888a9 fix(orbit): host single-track playTrack appends instead of replacing (#529)
* fix(orbit): host single-track playTrack appends instead of replacing

Reported by cucadmuh: "When playing from offline, the Orbit queue
doesn't get appended to — it gets overwritten."

Root cause: the Orbit bulk-guard fires only when `queue.length > 1`.
A `playTrack(track, [track])` call (one example: OfflineLibrary's
"Play this album" on a single-track album, but any UI that passes an
explicit 1-track replacement queue triggers it) slips past the guard
and replaces the host's `playerStore.queue`. The host's queue *is* the
shared Orbit queue — replacing it wipes every guest suggestion + every
upcoming track in one click.

Re-route to append + jump when role is host: if the track is already
in the queue, jump to that slot; otherwise append it and jump to the
new tail. The guest path is intentionally left alone — guests opting
out of host-sync via a local Play is the existing "guest running
their own show" divergence behaviour. `useOrbitGuest`'s `syncToHost`
is the only guest-side caller of `playTrack(track, [track])`, and it
never matches `role === 'host'` so it's never intercepted.

* docs(changelog): add host single-track Orbit-queue protection bullet
2026-05-09 22:53:04 +02:00
Frank Stellmacher eae649bcad fix(orbit): make initial-sync seek visually stick on join (#528)
* fix(orbit): make initial-sync seek visually stick on join

Reported: "I join the room, the waveform shows the host's live
position for a second or two, then snaps back to 0:00 and audio is
still playing from the start. Only after that snap can I press Catch
Up."

Two compounding causes in `useOrbitGuest`:

**1. Poll fires `applyMirror` before the engine is genuinely playing.**
   `playTrack` flips `isPlaying` to `true` *synchronously* in its
   optimistic store write, so the post-`playTrack` poll satisfied its
   "engine ready" check before the Tauri `audio_play` had even
   started producing sample. The `seek` inside `applyMirror` updates
   the store position immediately (waveform jumps to host's live
   position), but `audio_seek` is debounced and lands on a not-ready
   engine — it silently no-ops. The engine then starts playing from
   0, and its first progress events overwrite the optimistic seek
   position, snapping the waveform back. Add `currentTime > 0.1` to
   the poll's "engine genuinely playing" condition: once audio has
   flowed past the cold-start barrier, the seek commits and the
   seek-target guard correctly filters subsequent progress events.

**2. `applyMirror`'s play-state mirror raced its seek**, same shape
   as the `onCatchUp` race fixed in #527. `player.seek` debounces
   `audio_seek` via setTimeout(0) while `pause`/`resume` invoke
   synchronously — pause arriving first leaves the engine paused at
   the old position. Defer the play-state mirror by 200 ms so the
   seek lands first.

* docs(changelog): add initial-sync seek-stickiness bullet
2026-05-09 22:44:52 +02:00
Frank Stellmacher 6c5465e9c7 fix(orbit): hysteresis on Catch Up visibility so the button stays clickable (#527)
* fix(orbit): keep Catch Up button visible long enough to click

Reported during testing: the Catch Up button "appears for a moment,
then disappears too fast to click". The single-stage debounce only
filtered the show direction (require ≥ 3 s sustained over-threshold
before showing), but on a real-world high-latency session the genuine
drift fluctuates between ~1 s and ~8 s in lockstep with both sides'
chunked `currentTime` updates — so the button vanished as soon as
drift dipped briefly under 3 s, even though the drift baseline was
still 5–8 s.

Add a hide-side hysteresis: once visible, the button stays visible
until drift has been under a tighter 1 s threshold for ≥ 1 s.
Otherwise the noisy 1–3 s drift valleys keep the button up so the
user can actually click it.

Constants left as locals; if testing wants different floors we can
extract.

* docs(changelog): add catch-up hysteresis bullet

* fix(orbit): show Catch Up when host paused + serialise seek-then-pause

Two follow-ons after the hysteresis change:

**1. Show Catch Up even when the host is paused.** The visibility
   gate required `state.isPlaying === true`, but a guest who joined
   while the host was paused still benefits from Catch Up if their
   sync to the host's paused position failed (engine drop, brief
   network blip during initial-sync, manual local seek). Drop the
   `state.isPlaying` gate — the only signal that should matter is
   "drift between us and the host's reported state" — which
   `computeOrbitDriftMs` already computes correctly in the paused
   case (no time-extrapolation, just `guestPos - hostPos`).

**2. Defer the play-state mirror after `player.seek`.** Reported
   symptom: "I press Catch Up, the player pauses; when I press play,
   it resumes from the *old* position and the waveform jumps back."
   Root cause: `player.seek` debounces `audio_seek` via
   `setTimeout(0)`, while `player.pause` / `player.resume` fire their
   invokes synchronously. Pause arrives at the engine first, leaving
   it paused at the pre-Catch-Up position with the seek queued
   behind. When resume happens later, the engine plays from the old
   spot. Push the play-state mirror behind a 200 ms `setTimeout` so
   the seek's invoke lands first.
2026-05-09 22:37:45 +02:00
Frank Stellmacher 27c740b760 fix(orbit): kick a fresh playTrack when engine is stuck mid-load (#526)
* fix(orbit): kick a fresh playTrack when engine is stuck mid-load

Follow-up to #525. Symptom: occasionally on join, the guest gets no
audio until the next host-driven track change, at which point a
fresh `playTrack` runs and audio plays normally.

Root cause: when the initial `syncToHost` poll hits its 5 s deadline
without the engine reporting `isPlaying === true` (slow Navidrome
cold-start), the next pull tick takes the cheap "track already loaded"
shortcut and calls `applyMirror`. `applyMirror` fires `seek` + `resume`
on an engine that is stuck in a "loaded but never started" limbo —
seek silently no-ops and `resume` can't kick a track that never began.
Guest is silent until something else triggers a fresh `playTrack`.

Tighten the shortcut: take the cheap path only when the engine is
already in the state the host expects (or playing while host is
paused, which is fine to align via pause). Otherwise fall through to a
fresh `playTrack` and the existing 5 s ready-poll, which re-initialises
the engine and lets audio actually start.

* docs(changelog): add engine-limbo follow-up bullet

* fix(orbit): re-sync when engine silently fell back to paused

The optimistic `isPlaying: true` `playTrack` writes synchronously
masks a `audio_play` failure: the post-playTrack poll sees the
optimistic flag, fires `applyMirror`, and the outer tick records
`lastAppliedRef = { ..., isPlaying: true }` as a successful sync.
But if the underlying `invoke('audio_play')` rejects later (network
blip / cold-start exhaustion), the catch handler flips the flag back
to `false` and schedules `next()`, which short-circuits to `audio_stop`
in an Orbit guest — leaving the player silent while `lastAppliedRef`
still claims we're playing. None of the divergence-detection branches
(track-change / play-pause-flip) match, so the guest never re-syncs.

Add a recovery check before the if/else-if chain: when the captured
`last` says we applied playing, the engine is currently not playing,
and the host is still playing the same track, reset
`lastAppliedRef.current = null`. The next iteration of the chain
re-runs initial-sync (with the 500 ms fast-poll cadence) which fires
a fresh `playTrack`. If `audio_play` succeeds the second time, audio
finally starts; if it keeps failing, we loop with no extra harm
(audio was already silent).

Adds an `engine-recovery` event scope to the diagnostics buffer so
future captures can see when this re-sync fired.

* docs(changelog): expand engine-recovery bullet to cover both cases
2026-05-09 22:08:48 +02:00
Frank Stellmacher af1b9661f5 fix(orbit): three interlocking guest playback bugs (#525)
* fix(orbit): guest short-circuits queue-exhaustion fallback paths

When a guest's local queue runs out (single-track queue from `syncToHost`
empties on `audio:ended`), the player walks the standard fallback chain
in `next()`: radio top-up → infinite-queue → stop. The infinite-queue
branch builds a 6-track queue and calls `playTrack`, which trips
`orbitBulkGuard` and pops a "Add 6 tracks to the Orbit queue?" modal.
Hitting Cancel leaves playback frozen; "Add them all" injects unrelated
tracks into the host's shared queue.

In an active Orbit guest session the host owns the queue. Skip the
fallback paths entirely and just stop — the next `useOrbitGuest` pull
tick will sync to whatever the host advanced to.

Bonus side-effect: kills the deferred-promise race where a
`buildInfiniteQueueCandidates().then(...)` from a guest's track end
could resolve *after* a Catch Up replaced the queue and pop the modal
a second time against the now-current 1-track queue.

* fix(orbit): treat natural track-end as not-diverged in guest sync

When a guest's track ended naturally before the host advanced, the
divergence-detection branch read `player.isPlaying === false` and
classified it as the user manually paused — so it refused to load the
host's next track. The guest sat silent until they clicked Catch Up.

`handleAudioEnded` keeps `currentTrack` pinned to the just-ended track
and resets `currentTime` to 0, while a real manual pause leaves
`currentTime` somewhere mid-track. Use the 0-position discriminator to
classify natural-end as not-diverged so the host's new track loads.

Confirmed via the captured guest log buffer:
  18:43:08.598 [track-change] host: VJkV5… → 6i6RP… BUT guest diverged
                              (player.isPlaying=false ≠ last.isPlaying=true)
  — guest stuck for ~33s until Catch Up was pressed.

* fix(orbit): Catch Up polls until engine is ready before seeking

The 400 ms blind setTimeout in `onCatchUp` was too short for an
HTTP-streamed cold-start on high-latency links. If the audio engine
wasn't ready by then, `seek(fraction)` silently no-oped and playback
started at 0:00, making Catch Up effectively useless on exactly the
slow links where it's needed. Captured log shows a Catch Up bringing
the guest to posSec=30, then 5 s later the guest was at posSec=6
(playback restarted from the head).

Replace with the same poll-until-ready pattern `syncToHost` already
uses: check every 100 ms, fire the seek as soon as the engine reports
playing, fall back to a blind apply at the 4 s deadline.

* docs(changelog): add orbit guest playback fixes entry

* fix(orbit): debounce Catch Up button + match bar item height

Two follow-on UX fixes after PR #525's three primary bugs landed:

1. **Debounce visibility.** Drift is computed from an asymmetric signal:
   guest's `currentTime` updates in coarse ~5 s chunks, while host's
   position is extrapolated linearly via `(nowMs - posAt)`. Even on a
   perfectly-synced session the diff swings ±5 s every tick, so the
   button flickered in and out continuously. Show only after drift has
   stayed over the 3 s threshold for ≥ 3 s of wall clock — measurement
   noise is filtered out, real sustained drift still surfaces in time.

2. **Match neighbour height.** The button was 32 px tall against 26 px
   for the other action buttons (.orbit-bar__settings) so every flicker
   shifted the entire bar height. Set `height: 26 px` and tighten the
   padding/font so the layout is stable regardless of visibility.

* fix(orbit): tighten queue-extension lockout + reliable initial-sync seek

Two follow-on fixes after the 4-bug umbrella:

**1. Local queue-extension paths fully off during Orbit.**
Phase check broadened from `active` to cover `starting` / `joining` /
`active` so a fetch-then-join race can't pop the bulk-add modal *after*
the join. The proactive infinite-queue topper inside `next()` (which
fires when ≤ 2 auto-tracks remain ahead) is now also gated, plus each
async `.then()` callback in the radio + infinite-queue paths re-checks
at resolution time. A `playTrack(... 6-track queue ...)` after the user
joined Orbit was the path that re-triggered the "Add 5 tracks?" modal
on a freshly-joined guest.

**2. `syncToHost` only seeks once the engine reports playing.**
The previous 2 s deadline-fallback applied the seek even when the
engine hadn't started, where the seek silently no-ops and the track
plays from 0:00. Symptom: clicking Catch Up makes the song "jump 50 %
forward" — that's the seek finally landing because the engine is now
ready, the initial-sync seek had already failed silently. New deadline
is 5 s, and on timeout we return `false` so the outer pull tick keeps
`lastAppliedRef` null and the 500 ms fast-poll retries.

* fix(orbit): double-click play button + hide preview during session

Two cucadmuh-flagged gaps:

**1. Double-click on the inline play button now reaches the orbit-add
   path.** The album-track row's onDoubleClick already routes to
   `addTrackToOrbit` when in Orbit, but the inline play button stopped
   propagation on click — so clicking it twice just fired the "double-
   click to add" hint toast and never touched the orbit queue. Add an
   onDoubleClick on the button itself that delegates to the parent's
   `onDoubleClickSong`.

**2. Track preview is suppressed during an Orbit session.** Preview
   shares the Rust audio engine with the shared playback, so starting
   one as a guest yanks the host's track out from under everyone. A new
   `[data-orbit-active]` attribute on `<html>` (set whenever role is
   host/guest and phase is starting/joining/active) hides every
   preview button via a single CSS rule, and `previewStore.startPreview`
   short-circuits as a defensive guard for keyboard shortcuts and any
   programmatic callers.
2026-05-09 21:50:52 +02:00
Frank Stellmacher a702a5dd5b feat(orbit): in-app diagnostics popover with copyable event log (#524)
* feat(orbit): in-app diagnostics popover with copyable event log

Multiple users on Discord report Orbit guests stopping after the first
song with no errors anywhere — Settings → Debug → Export Logs is too
buried for non-technical reporters, and the relevant code branches
have no logging at all (silent fail). This adds a one-click "Copy log"
path right inside the Orbit session bar.

The new Activity-icon button next to Help opens a popover with:

- Live mini-display: role, host vs. guest track id + position, drift,
  age of the host's last state write — all updating once a second.
- Scrolling event log textarea fed by an in-memory ring (200 events).
- Copy + Clear buttons. Copy formats `[ISO] [scope] body` lines and
  drops them on the clipboard — paste straight into a Discord report.

Instrumentation lands at the previously-silent decision points:

- Guest pull tick: full snapshot of host vs. guest state on every read.
- Each branch of the divergence detection in `useOrbitGuest.ts` logs
  which path it took and why (initial / track-change-followed /
  track-change-diverged / play-pause-flip), making the
  "stuck after first song" symptom diagnosable from the buffer alone.
- Host pushes log track id, isPlaying, queue length, guest count.

Events are also bridged to the existing `frontend_debug_log` Tauri
command when Settings → Logging is on Debug, so power users still get
the same data in `psysonic-logs-*.log` for offline triage.

i18n: full `orbit.diag.*` namespace in all eight locales. EN + DE are
native; ES / FR / NB / NL / RU / ZH are first-pass and may want a
polish from native speakers later.

* docs(changelog): add orbit diagnostics popover entry
2026-05-09 21:49:17 +02:00
Frank Stellmacher 874b0c67ae fix(context-menu): drop inline z-index that hid menus under floating player (#522)
* fix(context-menu): drop inline z-index that hid menus under floating player

The main context menu wrapper carried an inline `zIndex: 999` that
overrode the `.context-menu { z-index: 10000 }` stylesheet rule. The
floating player bar sits at z-index 1000, so when a menu opened near
the bottom of the screen — long enough or anchored low — the player
bar covered it.

Removing the inline override lets the stylesheet rule (10000) win, so
the menu always paints on top of the floating bar. Submenus inherit
the same stacking context so they follow the parent menu.

Closes #521.

* docs(changelog): add context-menu floating-player z-index fix entry
2026-05-09 18:52:37 +02:00