Commit Graph

677 Commits

Author SHA1 Message Date
Frank Stellmacher 568f3aeb7d test(api): subsonic.ts async endpoint contracts (F3 follow-up) (#549)
39 new tests targeting the response normalization paths the F3 PR (#544)
deferred. Mocks axios at the module boundary; pins:

- api() helper envelope: unwrap subsonic-response on status=ok, throw
  "Invalid response" without envelope, throw the server message on
  status=failed, throw a generic on failed-without-message, propagate
  network failures.
- song-array vs single-object normalization paths:
  - getMusicDirectory normalizes child (object -> [object]), empty -> [].
  - getMusicIndexes flattens index -> artist arrays (object or array).
  - getMusicFolders coerces numeric ids to strings + defaults name.
  - getRandomSongs pass-through behaviour pinned (no normalization --
    Navidrome always returns the array form).
- collection-shape contracts: getAlbum splits { album, songs } + empty
  fallback when album.song is absent, getStarred returns empty arrays
  on missing starred2 / missing fields / pass-through arrays.
- single endpoint behaviours: getSong null on failure, getTopSongs []
  + slice to 5, getArtists flatten + empty, search whitespace-query
  short-circuit (no HTTP), getAlbumInfo2 null on error, ping
  true/false based on status.
- pingWithCredentials (explicit-URL path): full response (type +
  serverVersion + openSubsonic), http:// prepend when scheme missing,
  trailing-slash strip before /rest/ping.view, ok=false on any
  failure / status=failed, openSubsonic defaults to false when omitted.

subsonic.ts coverage 12.66% -> 31.87% lines (+19pp). The remaining
surface lives in niche endpoints (playlist mutations, statistics
overview/aggregates, internet radio CRUD, cover-art uploads, ratings
prefetch). Not gate-eligible yet; a follow-up could push to ~60% but
diminishing returns relative to other backlog items.

Frontend suite: 412 -> 451 tests (+39).
2026-05-11 23:41:08 +02:00
Frank Stellmacher 377675ae94 test(ui): MiniPlayer + FullscreenPlayer (§4.5 regression) (Phase F5c) (#548)
MiniPlayer.test.tsx (4): mounts without throwing, renders the
always-present titlebar controls (Pin + Open main window — Close is
Linux-only and lives on the manual smoke list per pick 4a), click on
Open main window / Close does not throw, clicking the Pin button
flips the alwaysOnTop label between "Unpin" and "Pin on top". Bridge
contract (mini:ready / mini:sync, geometry persistence) deferred to
B-tier phase B5 -- jsdom does not model two webviews.

FullscreenPlayer.test.tsx (9): renders the labelled Fullscreen Player
dialog + Close Fullscreen control. Control wiring: Close calls
onClose, Stop calls stop, Previous calls previous, Next calls next,
Repeat cycles via toggleRepeat.

§4.5 of the v2 plan -- useCachedUrl(coverUrl, coverKey, false)
regression. Mocks the CachedImage module so the call args are
observable. Pins:
  - the 500 px cover-art call passes opt=false (no fetchUrl fallback;
    prevents double crossfade fetchUrl -> blobUrl);
  - the 300 px art-box call passes opt=true (default behaviour).
A refactor that "tidies up" the useCachedUrl call sites would silently
regress the FS player cover; this test makes it loud.

Harness fix: vi.mock for @tauri-apps/api/event now returns an async
emit that resolves -- components chain .catch() on emit which crashed
on the bare vi.fn() return value during first-render useEffect.
Benefits any future component test that mounts something using emit.

Frontend suite: 399 -> 412 tests (+13). F5 (and Phases F0-F6) complete.
2026-05-11 23:24:40 +02:00
Frank Stellmacher 2c38db6ea6 test(ui): QueuePanel (§4.4 DnD regression) + PlayerBar (Phase F5b) (#547)
QueuePanel.test.tsx (8): empty-queue affordance, one row per queue track
with matching data-queue-idx, track titles render. Toolbar exposes
Shuffle queue / Save Playlist / Load Playlist / Copy queue share link /
Clear via aria-label; Shuffle is disabled when the queue has fewer than
2 tracks.

§4.4 of the v2 plan -- DnD architecture pin. Queue rows do NOT declare
draggable=true (no HTML5 native drag); the source file has no
dataTransfer.setData / dataTransfer.getData / onDragStart / onDrop JSX
usage; no application/json MIME anywhere. The project's psy-drop custom
event system sidesteps WebView2's text/plain-only restriction by
avoiding HTML5 DnD entirely -- a refactor that re-introduces native DnD
on the queue would silently break Windows.

PlayerBar.test.tsx (9): renders the labelled "Music Player" region.
Surfaces Previous Track / Play / Next Track / Repeat / Stop when a
track is loaded. The middle control flips between "Play" and "Pause"
based on isPlaying. Control wiring: clicking Play/Pause calls
togglePlay, Previous calls previous, Next calls next, Repeat cycles
off -> all -> one, Stop calls stop. The region landmark is still
rendered when no track is loaded.

Adds a generic scrollIntoView no-op stub in src/test/mocks/browser.ts --
jsdom does not implement it and QueuePanel's queueAutoScroll calls it
on mount with auto-advance enabled. Benefits any future component test
that touches scroll affordances.

Frontend suite: 382 -> 399 tests (+17).
2026-05-11 23:16:08 +02:00
Frank Stellmacher fae615fdb8 test(ui): ContextMenu + WaveformSeek behaviour pins (Phase F5a) (#546)
ContextMenu.test.tsx (11): renders nothing when closed, renders when
openContextMenu has run, closeContextMenu hides on next render. Song-type
shows Play Now / Play Next / Add to Queue; album-type shows Open Album
/ Play Next / Enqueue Album / Go to Artist; artist-type shows Start
Radio + share affordances; queue-item shows a Remove option the song
menu does not. Clicking an action calls the expected store method
(playNext, enqueue) and closes the menu. Escape on the menu closes it.

WaveformSeek.test.tsx (8): renders a canvas, cursor=default without
trackId (no-track-loaded affordance), cursor=pointer with a trackId.
Wheel guards: no seek without a trackId, no seek with duration=0. Wheel
commit wiring: 350 ms trailing debounce delays the seek call until
activity settles; rapid wheel events coalesce (fewer commits than
events). Mount + unmount completes without throwing.

Neither component joins the hot-path gate -- jsdom skips canvas drawing
and these files have large branch surfaces (ContextMenu has 13 menu
types, WaveformSeek has the animation loop + 11 seekbar styles) that
need behavioural smoke rather than line coverage. The tests cover the
contract the refactor must preserve; visual / interactive layers stay
on the manual smoke list.

Frontend suite: 363 -> 382 tests (+19).
2026-05-11 23:07:58 +02:00
Frank Stellmacher 6e646351ee test(previewStore): startPreview + main-player volume sync (Phase F4) (#545)
Adds 22 new tests on top of the existing 7 _on* / stopPreview ones.

startPreview happy path: invokes audio_preview_play with the configured
args (id, url, durationSec, startSec, volume) and stores the previewing
track + duration + reset elapsed / audioStarted. Short tracks
(duration <= previewDuration * 1.5) start at 0; longer tracks seek to
duration * trackPreviewStartRatio. Camel-case IPC keys pinned
(startSec / durationSec, not snake_case -- CLAUDE.md gotcha).

Cross-store guard tests: no-op when previews globally disabled, no-op
when disabled at the calling location, no-op while a host or guest is
inside any Orbit phase (active / joining / starting), falls through to
play when role is null (no session).

Same-id re-click: treats it as a stop -- audio_preview_stop fires,
audio_preview_play does not.

Failure path: engine invoke rejects -> store state rolls back
(previewingId / previewingTrack / audioStarted) and the error propagates
to the caller.

Loudness pre-attenuation folding: with normalizationEngine=loudness +
loudnessPreAnalysisAttenuationDb=-6 dB, volume is multiplied by
10^(-6/20). normalizationEngine=off keeps volume verbatim. Positive
pre-attenuation values are pulled to 0 by the Math.min(0, ...) guard.

Main-player volume sync side-effect (module-level
usePlayerStore.subscribe): pings audio_preview_set_volume when volume
changes during a preview, skips when no preview is active, skips when
the new value equals the prior value (subscription guard).

previewStore.ts coverage 33% -> 100% lines. Added to the hot-path gate.
Plus the typed `OrbitRole` is `'host' | 'guest'` (null when no session),
not 'idle' as a string -- minor type-correctness alignment.
2026-05-11 22:57:26 +02:00
Frank Stellmacher d2898ebaf6 test(api): URL builders + playback URL resolver + share link composition (Phase F3) (#544)
subsonic.contract.test.ts (21): parseSubsonicEntityStarRating (userRating
first then rating fallback, numeric-string coercion, undefined for null /
NaN / non-numeric), libraryFilterParams (empty without active server, empty
on "all" filter, returns musicFolderId on specific filter), getClient
(throws without a server, returns baseUrl + auth params, rotates token + salt
across calls), coverArtCacheKey (serverId:cover:id:size shape, "_" fallback
without active server, no ephemeral salt embedded -- stays cacheable),
buildStreamUrl (URL shape + Subsonic auth params: id u t s v=1.16.1
c=psysonic/* f=json, rotates t/s across calls so Rust matches by id, special
character ids encoded once not twice), buildCoverArtUrl (default size=256),
buildDownloadUrl (download.view path), trailing-slash + scheme handling on
base URL.

resolvePlaybackUrl.test.ts (15): precedence offline > hot-cache > stream
(first priority wins even when later sources also have the track), forwards
trackId + serverId to both stores. getPlaybackSourceKind for offline / hot
/ stream / engine-preload-hint cases. streamUrlTrackId parser (id from
stream.view query, null for non-stream URLs / no query / missing id, decodes
URL-encoded ids, manual-query fallback for relative paths).

copyEntityShareLink.test.ts (5): writes a psysonic2-prefixed payload that
round-trips, returns false without an active server, returns false on
empty / whitespace id, trims surrounding whitespace before encoding,
propagates clipboard-failure return.

Gate broadens with src/utils/resolvePlaybackUrl.ts (95.8 %) +
src/utils/copyEntityShareLink.ts (100 %). subsonic.ts at 12.7 % stays out
-- the URL-builder + parser surface this PR covers is the structural part;
the async API endpoints need axios mocking, deferred to a follow-up.
authStore.ts (79 %) and playerStore.ts (40 %) deferred-list comments
updated to reflect F2 + F1 actuals.
2026-05-11 22:51:29 +02:00
Frank Stellmacher ae23bf61eb test(authStore): characterize login + servers + persistence + settings (Phase F2) (#543)
login.test.ts: composed addServer -> setActiveServer -> setLoggedIn flow,
failed-login leaves prior state intact, addServer assigns unique ids,
setConnecting / setConnectionError independence, logout clears
isLoggedIn + musicFolders but keeps server entry, Last.fm session
(setLastfm / connectLastfm / disconnectLastfm contracts).

servers.test.ts: addServer / updateServer (patch by id, no-op on unknown),
setActiveServer (clears musicFolders), removeServer (non-active no-effect,
active picks newServers[0] fallback, last server -> null + isLoggedIn
false, cleans per-server bookkeeping maps), getActiveServer / getBaseUrl
selectors. Includes the gapless / crossfade mutex regression test from
the v2 plan section 4.3 -- callers clear the other flag before setting,
the setters themselves do NOT auto-clear (contract pin).

persistence.test.ts: hydration loads existing localStorage shape, defaults
missing fields, preserves saved values verbatim. Robust to corrupt JSON
and missing top-level state. onRehydrate migrations: clears conflicting
hotCacheEnabled + preloadMode!=off legacy combo, keeps hotCache when
preload was already off, migrates legacy waveform seekbarStyle to truewave,
strips removed animationMode / reducedAnimations fields. partialize strips
musicFolders. Includes the synchronous-storage invariant regression from
the v2 plan section 2 (CLAUDE.md gotcha) -- getActiveServer is visible in
the same tick after addServer + setActiveServer with no await.

settings.test.ts: API-pin sweep across 22 trivial setters via it.each
(rename-resistant), focused tests for setters with logic (clamping in
setTrackPreviewStartRatio / setTrackPreviewDurationSec / setRandomMixSize,
boolean coercion in setTrackPreviewsEnabled, finite-number guard in
setLoudnessPreAnalysisAttenuationDb, default reset in
resetLoudnessPreAnalysisAttenuationDbDefault), per-server bookkeeping
contracts (setEntityRatingSupport / setAudiomuseNavidromeEnabled
positive-opt-in semantics), enum-value setters
(setPreloadMode / setDiscordCoverSource / setLoggingMode / setReplayGainMode /
setNormalizationEngine / setLyricsMode), genre blacklist + audio output
device replacement.

authStore.ts coverage 45.37% -> 79.29% lines (target was 60%). authStore.ts
not yet added to the hot-path gate -- want to see it stable across a few
real PRs first per the gate's curation rule.
2026-05-11 22:30:45 +02:00
Frank Stellmacher 8569a17797 test(playerStore): progress snapshot + persistence flush (Phase F1 / PR 2c) (#542)
progress.test.ts: getPlaybackProgressSnapshot shape + post-emit reflection,
subscribePlaybackProgress (notify, (next, prev) pair, near-duplicate epsilon
coalesce at 0.005 s, unsub stops notifications, multiple subscribers
independent), live-emit throttling guard (drops within 1500 ms + < 0.9 s
delta; large delta passes; time threshold passes).

persistence.test.ts: flushPlayQueuePosition forwards (ids, currentId, posMs)
to savePlayQueue, caps song-id list at 1000, no-ops on radio / no-track /
empty queue, swallows backend errors, floors position to whole ms.

playerStore.ts coverage 39.55% -> 40.48%. F1 50% floor not met -- remaining
~10pp lives in playTrack's async hot-cache/replay-gain body, shuffleQueue,
stop, enqueueRadio, initializeFromServerQueue and the orbit auto-merge
paths. Either a follow-up PR 2d or a revised F1 floor; flagged in the PR
body. Gate unchanged -- playerStore.ts stays out until the floor is met.

Discovered + fixed during this PR: the spread ...await vi.importActual()
mock pattern lets the real savePlayQueue leak through to playerStore.ts's
relative import (../api/subsonic) even when the alias-form is mocked.
Switched persistence.test.ts to an explicit non-spread mock map listing
every export the store uses.
2026-05-11 22:14:57 +02:00
Frank Stellmacher 4a18e15489 test(playerStore): playback actions + audio event handlers (Phase F1 / PR 2b) (#541)
playbackActions.test.ts: pause (invoke + failed-invoke controlled), resume
(warm path, no-track guard), togglePlay (both branches), seek (clamp to
dur-0.25, 100 ms debounce, coalesce rapid drags, no-op guards), next
(advance, repeat=all wrap, repeat=off audio_stop + reset), previous
(>3 s restart, jump-back, queueIndex=0 no-op), toggleRepeat cycle.

events.test.ts: audio:progress (commit on active transport, drop without
track / paused, duration=0 falls back to track.duration), audio:track_switched
(advance, repeat=one pin, repeat=all wrap, end+off no-op, scrobbled+
lastfmLoved reset), audio:ended (immediate playback reset, radio path
clears currentRadio without queue advance). 4 listener-lifecycle regression
tests cover section 4.2 of the pre-refactor testing plan v2 -- cleanup
actually unsubs; re-init keeps count=1; double-init without cleanup stacks
(contract pin so a refactor that drops the cleanup return value fails loudly).

playerStore.ts coverage 18.1% -> 39.55% lines. PR 2c (progress snapshot +
persistence flush) pushes past the F1 50% floor.
2026-05-11 21:56:24 +02:00
Frank Stellmacher 42e3fdb976 test(playerStore): characterize pure helpers + queue mutations (Phase F1 / PR 2a) (#540)
trackShape.test.ts: songToTrack mapping (required, optional, replayGain
flattening, missing-block fallback, no invented flags); resolveReplayGainDb
precedence (disabled, track, album, auto with prev/next neighbour match,
missing albumId fallback, both gains missing); shuffleArray (non-mutating,
preserves multiset, copies, empty/single, deterministic under mocked RNG).

queue.test.ts: enqueue / enqueueAt (auto-added separator placement,
queueIndex shift, index clamping), playNext (playNextAdded tag, position,
empty input), clearQueue (state reset, audio_stop call), reorderQueue
(splice, currentTrack-id-following queueIndex), removeTrack (clamp on
shrink, keep when after cursor), undo / redo (empty stacks return false,
rollback, replay, new edit drops pending redo).

Adds a 5-line test-only export _resetQueueUndoStacksForTest in playerStore.ts
because the undo/redo arrays live at module scope outside the Zustand state
graph; storeReset.ts now calls it so resetPlayerStore is fully complete.

playerStore.ts coverage 4.76% -> 18.1% lines. PR 2b + 2c push further toward
the F1 floor of 50%.
2026-05-11 21:40:47 +02:00
Frank Stellmacher 4f9ad07d65 test(frontend): harness expansion + utility coverage push (F0 + F6) (#539)
* test(frontend): expand harness for store/component/contract tests

- factories: makeSubsonicSong, makeServer, makeAuthState, makeQueueState
- storeReset.ts: per-test reset for player/auth/preview/orbit stores
- mocks/subsonic.ts: realistic fixtures + stream/cover URL helpers
- mocks/browser.ts: ResizeObserver/IntersectionObserver/matchMedia/clipboard/object URLs
- mocks/tauri.ts: tauriMockListenerCount for listener-lifecycle regression tests
- renderWithProviders: pin i18n language to 'en' by default; { language } opt-out
- vitest.config: pool 'forks' + isolate to avoid module-mock + Zustand-global flake
- README: documented patterns, store-reset policy, i18n rule, isolation rationale

* test(frontend): bump utility coverage + expand hot-path gate

serverMagicString: 71→100% (encode/decode rejection branches, clipboard
fallback paths). shareLink: 69→97% (all entity kinds, queue trim, orbit
decoder, findServerIdForShareUrl). dynamicColors: 44→100% (extractCoverColors
DOM paths via Image / canvas / fetch mocks).

Gate adds shareLink.ts and dynamicColors.ts — both stable above 95%.
Comments updated for the new floor and the M4 hard-gate handoff.
2026-05-11 21:11:23 +02:00
Frank Stellmacher a228ce1c91 chore: fix stale doc references (#538)
- src/test/README.md: layout listed wrong filename for the readme itself
- miniPlayerBridge.ts: comment pointed at a doc that lives outside the repo
2026-05-11 16:57:54 +02: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
Frank Stellmacher 02d533e949 test(frontend): vitest framework bootstrap + hot-path coverage gate (#536)
* test(frontend): vitest framework bootstrap + hot-path file coverage gate

Adds the harness for component, hook and store tests on top of the existing
util tests in src/utils/. Mirrors the backend rust-tests rollout: jsdom env,
v8 coverage, soft hot-path file gate, dedicated CI workflow.

What's in:
- vitest.config.ts: jsdom environment, v8 coverage, alias @ -> src
- src/test/setup.ts: jest-dom, @testing-library cleanup, vi.mock for
  @tauri-apps/api/{core,event} + plugin-shell, Map-backed Storage polyfill
  for Node 25 + jsdom 26 (both ship a broken native localStorage)
- src/test/mocks/tauri.ts: programmable onInvoke() / emitTauriEvent() helpers,
  auto-reset between tests
- src/test/helpers/factories.ts: makeTrack / makeTracks
- src/test/helpers/renderWithProviders.tsx: render() wrapped with
  MemoryRouter + I18nextProvider
- src/test/README.md: conventions doc (where tests go, how to mock Tauri,
  what to never mock)

Sample tests showing the patterns:
- src/components/CoverLightbox.test.tsx: component, queries by role
- src/store/previewStore.test.ts: store characterization, event handlers
  + stopPreview (startPreview deferred until the cross-store provider
  strategy is decided)

CI:
- .github/workflows/frontend-tests.yml: jobs for vitest, tsc, coverage +
  hot-path gate. coverage job carries continue-on-error: true (soft).
- .github/frontend-hot-path-files.txt: initial list (3 utils at >=70%).
  playerStore + the unfinished half of previewStore are deferred until
  Phase 1 coverage work lands.
- scripts/check-frontend-hot-path-coverage.sh: mirror of the rust gate.

npm scripts:
- test: one-shot run (unchanged)
- test vitest in watch mode
- test:coverage: v8 coverage + html / lcov / json-summary reports

57 / 57 tests pass; tsc --noEmit clean.

* chore(nix): sync npmDepsHash with package-lock.json

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-11 12:25:48 +02:00
Maxim Isaev 308eb36f05 feat(analysis): re-analyze waveform when clearing loudness cache
Add analysis_delete_waveform_for_track, invoke it from loudness reseed,
clear waveformBins in the UI, and extend queue strings for tooltips/toast.
2026-05-10 01:34:40 +03: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
Frank Stellmacher fec513b629 fix(home): swap Because-you-listened rail to AlbumRow under 696 px (#520)
* fix(home): swap Because-you-listened rail to AlbumRow under 696 px

The hero-style BecauseCards are tuned for full-rail widths (3 cards at
1052 px+, 2 cards at 696-1051 px). Below that the cards stretched
full-width with a fixed 160 px cover stuck on the left and centred text
floating in a wide empty area — looked like three over-sized banners
stacked vertically instead of a compact recommendation rail.

A `ResizeObserver` on the rail wrapper now watches the container width
and below 696 px renders a standard `AlbumRow` (which is already
perf-tuned for narrow rails: artwork budget, viewport windowing, scroll
paging). Wide layouts keep the unchanged hero card layout, so the
mainstage view at full width is identical to before.

* docs(changelog): add Because-you-listened narrow-layout fix entry
2026-05-09 18:11:22 +02:00
Frank Stellmacher 8b781a848d refactor(i18n): show language names as endonyms in picker (#514)
* refactor(i18n): show language names as endonyms in picker

* docs(changelog): add language-endonyms entry for #514
2026-05-08 00:19:35 +02:00
Frank Stellmacher c982362884 fix(i18n): OpenDyslexic font supports Cyrillic (#513)
* fix(i18n): OpenDyslexic font supports Cyrillic

The OpenDyslexic subtitle in the font picker stated "no RU/ZH support",
but the bundled `@fontsource/opendyslexic` 5.x ships Cyrillic glyphs and
Russian renders correctly — verified empirically. Only Chinese (CJK)
actually falls back to the system font. Updated the subtitle string in
all 8 locales accordingly; the Russian locale itself no longer claims
unsupported-self.

* docs(changelog): correct OpenDyslexic locale-coverage claim (#513)

The #507 entry in the unreleased v1.46.0 block claimed `Latin +
Latin-extended only` and listed Cyrillic among the system-font
fallbacks. Cyrillic glyphs ship in @fontsource/opendyslexic 5.x and
render correctly — only Chinese (CJK) actually falls back. Updated the
bullet in place to match the corrected subtitle hints from this PR.
2026-05-07 23:55:24 +02:00
Frank Stellmacher 6f50fb6a19 feat(player-bar): album context menu on song title right-click (#512)
* feat(player-bar): album context menu on song title right-click

Right-clicking the track title in the player bar now opens the same
album context menu that album cards use (open, play next, enqueue,
go to artist, favorite, rate, share, download, add to playlist).

Mirrors the existing left-click behavior on the title, which already
navigates to the album. Suppressed for radio and preview, matching
the click handler.

MarqueeText gains an optional onContextMenu prop; PlayerBar builds a
SubsonicAlbum shape from currentTrack on demand.

* docs(changelog): add entry for PR #512 (player-bar title context menu)
2026-05-07 23:33:25 +02:00
Frank Stellmacher 57fe847d71 refactor(settings): collapse all sections, drop font dropdown, surface OpenDyslexic (#508)
* refactor(settings): collapse all sections, drop font dropdown, surface OpenDyslexic

Settings opened on a tab where four or five sub-sections were expanded
on first render — audio device, theme list, lyrics sources, sidebar
customizer, random-mix copy, offline dir, language picker, keybindings
table. The page felt like a wall of controls before the user had even
looked for something specific. Removed every `defaultOpen` flag from
the SettingsSubSection call sites so each tab now boots with only the
section headers visible. Component default was already `false`.

ThemePicker auto-expanded the group containing the active theme on
mount. Same noise on a screen that already has the longest accordion
list in the app, and the blue dot in the group header already tells
the user which group holds the active theme. Initial open-group is
now `null` — all groups collapsed until the user clicks one.

Font picker had a dropdown-style button that toggled a list inside
the sub-section, which meant two clicks (open the section, then open
the dropdown) for what should be a one-click choice. Removed the
button + the `fontPickerOpen` state — opening the Font sub-section
now reveals the full list directly and a click sets the font without
collapsing anything. OpenDyslexic moved to the top of the list so
users with dyslexia don't scroll past 14 sans-serifs to find their
option; the rest stays in the original order.

* docs: changelog entry for PR #508

Logs the Settings collapse-by-default + font picker cleanup +
OpenDyslexic ordering in v1.46.0 "## Changed".
2026-05-07 22:31:50 +02:00
Frank Stellmacher f520f7951a feat(settings): OpenDyslexic font option for dyslexic readers (#507)
* feat(settings): OpenDyslexic font option for dyslexic readers

Next step on the accessibility track. The first pass was on the colour
side — WCAG contrast audits across every theme and dedicated colour-
vision-deficiency variants for the protanopia / deuteranopia / tritan-
opia palettes. Typography is the other axis: some users with dyslexia
find a font with a heavier weighted baseline and asymmetric glyph
shapes (b/d, p/q never mirror, italic forms differentiated rather
than slanted-regular) easier to track than a typical sans.

Adds OpenDyslexic to the existing Fontsource font picker. SIL OFL
licensed, freely redistributable, and the de-facto open-source
standard for this use case. Non-variable axis, ships as four discrete
weight/style files (regular, bold, italic, bold-italic) — the Settings
picker grew an optional `hint` field on font entries so this one row
can carry a "dyslexia-friendly · no RU/ZH support" subtitle without
bloating the other 14 entries.

Latin + Latin-extended only. Cyrillic and CJK locales (RU, ZH) fall
back to the system font when this is selected; the subtitle calls out
that limitation upfront. i18n: hint string in all 8 locales
(settings.fontHintOpenDyslexic).

Accessibility is intentional product positioning here — it's an
underserved corner of the Subsonic-client ecosystem.

* chore(nix): sync npmDepsHash with package-lock.json

* docs: changelog entry for PR #507

Logs the OpenDyslexic font option in v1.46.0 "## Added".

* docs(settings): contributor entry for PR #507

Adds the OpenDyslexic accessibility bullet to Psychotoxical's
contributions list.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-07 22:15:38 +02:00
Frank Stellmacher bd56177e2c feat(home): Lossless Albums rail + dedicated page + sidebar nav (#506)
* wip(home): Lossless rail + dedicated /lossless-albums page

Rail under Home > mostPlayed and a dedicated infinite-scroll page that
list albums whose tracks are tagged in lossless containers. Walks
Navidrome's native /api/song?_sort=bit_depth&_order=DESC, dedupes by
albumId on the way down, stops when the song stream crosses into lossy
(bitDepth==0) or the server runs out of rows. _filters has no operators
on quality columns, so a sort + walk is the only path; equality on
sample_rate / bit_depth probes returned empty (verified).

The page paginates through the song-cursor with an in-flight ref so
overlapping IntersectionObserver fires don't double-add albums, plus a
cancelled flag so React StrictMode's double-mount doesn't apply two
parallel result sets in dev.

Suffix allowlist excludes ambiguous wrappers — m4a/m4b can be ALAC
*or* AAC and Navidrome's response carries an empty codec field, so we
can't tell them apart; same story for wma. Allowlist is flac, wav,
aiff/aif, dsf/dff, ape, wv, shn, tta — containers that are only
lossless. ALAC-in-m4a setups will miss out, acceptable trade-off
without a reliable codec field.

Status: WIP. Settings home-customizer label and en.ts strings landed,
no other locales yet, no quality badge on AlbumCards across the rest of
the app, no CHANGELOG. Branch was 'feat/home-hires-rail' during the
earlier hi-res-only iteration before the lossless broadening.

* wip(home): Lossless page header parity + streaming + sidebar nav

Page header now mirrors All Albums: selection mode with three action
buttons (Enqueue Selected, Add Offline, Download ZIPs) wired to the
same handlers Albums.tsx uses, selection-counter title swap, and the
perfFlags.disableMainstageStickyHeader respect path. Filters were
intentionally skipped — the rail is sorted by bit_depth, mixing client-
side filters with server-driven pagination would produce gaps.

Loading feels noticeably faster: ndListLosslessAlbumsPage takes an
optional onProgress callback that fires once per internal fetch with
the entries discovered in that fetch, so the page can stream new
albums into state instead of waiting on the whole loadMore. Page-side
budget dropped from 5×200 to 2×100 songs per loadMore (~1 MB worst
case vs 5 MB before), since the rail's catch-em-all pass is wrong for
infinite-scroll UX. Subtitle under the page title primes the user that
this is slower than other album pages because Psysonic walks the song
catalog by quality (Navidrome ignores _fields, so per-song responses
ship with lyrics + tags + participants whether we want them or not).

Sidebar nav entry registered under 'losslessAlbums' with a Gem icon,
defaults to visible:false (matches composers / folderBrowser /
deviceSync — niche browsing modes). Existing users get the entry
appended at the end of their persisted sidebar list automatically via
the onRehydrateStorage merge that sidebarStore already runs for new
DEFAULT_SIDEBAR_ITEMS.

i18n: full coverage across all 8 locales for sidebar.losslessAlbums,
home.losslessAlbums, losslessAlbums.empty, losslessAlbums.unsupported
and the new losslessAlbums.slowFetchHint subtitle. ru/zh are
machine-translation quality, flagged for a polish pass.

* feat(home): default Lossless sidebar entry to visible

Flips the DEFAULT_SIDEBAR_ITEMS entry for `losslessAlbums` from false
to true. Existing installs keep whatever the user has in persisted
storage; fresh installs see the entry in the sidebar from the start.

Earlier wip commit defaulted it off (matched composers / folderBrowser
/ deviceSync as a niche browse mode), but the rail + page do show
something useful for any library with at least one FLAC/WAV/etc. album,
so off-by-default just hid the feature.

* docs: changelog entry for PR #506

Logs the Lossless Albums rail + page + sidebar entry in v1.46.0
"## Added".

* docs(settings): contributor entry for PR #506

Adds the Lossless Albums rail/page bullet to Psychotoxical's
contributions list.
2026-05-07 20:44:27 +02:00
Frank Stellmacher a41e3a624a feat(song-info): show absolute file path on Navidrome via native API (#504)
* feat(song-info): show absolute file path on Navidrome via native API

Subsonic's `getSong.view` returns at most a relative path (or none on
Navidrome), so the Path row in the Song Info modal stayed empty for
most users. Feishin and the Navidrome web client surface the full
server-side path by hitting Navidrome's native `/api/song/{id}` instead.

Added an `nd_get_song_path` Tauri command that logs in to the native
API with the active server's credentials, fetches the song, and returns
the `path` string. Wired it into `SongInfoModal`: the Subsonic
`getSong` call still drives the rest of the dialog, and the native call
runs in parallel only when the active server's identity is
"navidrome". When it returns a path, that absolute path replaces the
relative Subsonic value; native-API failures are silent and the modal
falls back to whatever Subsonic provided.

No token cache yet — the modal is opened occasionally enough that one
fresh login per call is fine.

Closes discussion #479.

* docs: changelog entry for PR #504

Logs the Navidrome native-API absolute file path support for the
Song Info dialog in v1.46.0 "## Added".

* docs: settings contributor entry for PR #504, drop competitor mention

Adds the song-info absolute path bullet to Psychotoxical's contributors
list in Settings, and rewords the existing CHANGELOG entry so neither
text references competing clients.
2026-05-07 20:22:55 +02:00
Frank Stellmacher 726f3f0ff2 fix(radio): queue navigation, dedup, and similar-first variety (#500) (#503)
* fix(radio): queue navigation, dedup, and similar-first variety (#500)

After a Radio session ran a while, three things broke:

Queue navigation through duplicates. playTrack re-resolved the active
queue index by `findIndex(t.id === track.id)`, returning the *first*
matching id, so reaching the second occurrence of a track snapped
queueIndex back to the earlier slot — highlight jumped and the next
advance played the wrong follow-up. Added an optional
`targetQueueIndex` to playTrack, threaded through next(), previous(),
the audio:ended repeat-one path, queue-row click, and the queue-item
context menu. findIndex stays as the fallback for callers that just
have a track and a fresh queue.

Queue accumulation. enqueueRadio didn't dedupe incoming tracks; the
next() top-up deduped against the live queue but trimmed the played
tail down to HISTORY_KEEP=5, so a song heard 8 ago was gone from
`existingIds` and a later Last.fm/topSongs response could re-add it;
and the `.filter(...)` pass admitted intra-batch repeats (top +
similar overlap is common) because it read the dedup set before
mutating it. A module-level radioSessionSeenIds set, fed by
enqueueRadio and both top-up paths and reset on artist change and
clearQueue, closes all three: trimmed ids stay in the set, ids about
to be replaced (fresh enqueueRadio wiping the pending radio block)
are removed first so callers can re-introduce them, and the dedup
pass mutates the set inline.

Variety. Starting Radio on a track stacked five top tracks of the
seed artist before any similar-artist material played. Switched the
seed path and both top-up paths to lead with similar songs (other
artists) and only fall back to top tracks when similar comes back
empty — preserves the "no Last.fm" graceful degradation but stops
the seed artist from monopolising the front of the queue.

Not affected: gapless audio:track_switched (already index-based, no
findIndex), AudioMuse Instant Mix / Lucky Mix (single-element queues
or enqueue-only paths), the artist-radio path (no seedTrack — already
picks just one top track and fills the rest from similar).

Reported by netherguy4.

* docs: changelog entry for PR #503

Logs the radio queue navigation/dedup/similar-first fix in v1.46.0
"## Fixed".
2026-05-07 19:56:57 +02:00
Frank Stellmacher d7ff1d3113 fix(preview): keep preview sink volume in sync with player slider (#498) (#502)
* fix(preview): keep preview sink volume in sync with player slider (#498)

The Rust preview sink had its volume set once at audio_preview_play and
then never updated. audio_set_volume only ramps the main sink, so slider
movements during a preview had zero effect on the preview level. With
the default loudness normalization (-4.5 dB pre-analysis attenuation)
applied at start, even a 100% slider gives 1.0 × 0.596 × MASTER_HEADROOM
≈ 53% — matching the user-visible "fixed at around 50%" symptom.

- Add audio_preview_set_volume Rust command that updates the preview
  sink if one is active (clamp + master headroom mirror the path used
  in audio_preview_play).
- Extract the preview-volume calculation in previewStore into
  computePreviewVolume() so startPreview and the new sync path share
  one formula (slider value, plus the LUFS pre-analysis attenuation
  the engine already applies to the main sink).
- Subscribe to playerStore at module level: when volume changes and a
  preview is active, push the recomputed value to Rust. Auth /
  normalization tweaks during preview are intentionally not synced —
  preview is short and that case is rare.

Reported by netherguy4.

* docs: changelog entry for PR #502

Logs the preview-volume-slider sync fix in v1.46.0 "## Fixed".
2026-05-07 19:10:18 +02:00
Frank Stellmacher ddb1f29af9 refactor(settings): remove redundant Animations 3-state setting (#495)
* refactor(settings): remove redundant Animations 3-state setting under Seekbar Style

The `animationMode` setting (Full / Reduced / Static) duplicated work
the perf-flag system and OS-level reduced-motion preference already
covered:

- `perfFlags.disableMarqueeScroll` already kills marquee scrolling on
  demand, replacing what `static` mode used to gate.
- The `data-perf-disable-animations` html-level switch already strips
  every `*` animation, replacing what `static` mode used to do globally.
- `@media (prefers-reduced-motion: reduce)` honours the OS setting for
  every user that asked for it via system preferences.
- The 30 fps cap that `reduced` mode applied to the seekbar wave was
  better served by per-feature perf toggles cucadmuh added later.

Removed:
- `AnimationMode` type, `animationMode` field + setter from auth store.
- Settings UI block (3 buttons + hint text) under Appearance > Seekbar
  Style.
- `animationMode === 'static'` short-circuit in WaveformSeek's rAF
  effect; `isReduced` skip-every-other-frame logic; `static`-checks in
  `drawNow` / `needsDirectDraw`.
- `animationMode !== 'static'` guard and `data-anim-mode` attribute in
  MarqueeText.
- `[data-anim-mode="static"]` and `[data-anim-mode="reduced"]` rules in
  layout.css.
- Seven i18n keys (animationMode + 6 variants) across all eight
  locales.

Migration: the persist layer strips `animationMode` (and the legacy
`reducedAnimations` boolean predecessor) so anyone who had `'reduced'`
or `'static'` selected silently lands on the former `'full'` path on
first launch after upgrade. No user-facing prompt — the missing setting
just stops existing.

cucadmuh's PR #472 (FPS overlay), #476 (preview-freeze main seekbar,
sleep-recovery hooks, card-hover removal) and #486 (interpolation
anchor reset on resume) are all preserved untouched — they live in
separate effects / files and were not driven by `animationMode`.

* docs(changelog): add Removed section for animationMode setting (PR #495)

* docs(changelog): refine animationMode removal rationale (drop prefers-reduced-motion overstatement)
2026-05-07 12:53:19 +02:00
Frank Stellmacher ba73649360 fix(home): more variety + reliable render in Because-you-listened rail (#494)
Two reports rolled into one pass:

1. Rail occasionally rendered nothing on Home open. Cause: the rotated
   anchor sometimes had no Last.fm similar-artists or no library matches
   among the sampled set, and the old code gave up after one try and
   stored that dud anchor as the rotation cursor — so the next mount
   started from the same dud's neighbour and could fail again.

2. Recommendations felt repetitive. Cause: pool of 12 anchors walked
   round-robin made each anchor recur every 12 mounts, similar-artist
   sample of 6 from 12 had heavy overlap visit-to-visit, and per-artist
   single-album random pick meant artists with one library album always
   surfaced the same record.

Anchor selection: random pick from pool with a per-server cooldown
buffer (last 5 anchors excluded, capped at floor(pool/2) so small
libraries don't soft-lock). Up to 4 anchors are tried in a shuffled
candidates list before giving up; the localStorage cursor only advances
on a successful anchor so duds don't poison future mounts.

Picks variety: similar-artist fetch raised from 12 to 25 (same
getArtistInfo call, larger response — Last.fm typically returns up to
~50). Per-server ring buffer of the last 30 shown album ids; per-similar
-artist album choice prefers an album not in that buffer, falling back
to any album when the artist's whole catalogue is stale so the slot is
never lost.

Pool cap raised 12 -> 20 to give the cooldown buffer room to breathe in
libraries with varied listening history.

Storage: legacy `psysonic_because_anchor:` single-id keys from the
round-robin era are stripped on module load (one-shot localStorage
sweep, the new `..._anchor_history:` prefix has a different colon
position so no false matches).

API budget unchanged in the hot path: 1 getArtistInfo + 6 getArtist
per Home mount. Worst case (3 dud anchors, 4th succeeds) is 4
getArtistInfo + 6 getArtist.
2026-05-07 10:40:12 +02:00
Frank Stellmacher d75670ec4b feat(home): broaden Because-you-like seed pool + tidy orphan card at 1080p (#493)
* feat(home): mix recently-played + starred into Because-you-like anchor pool

Anchor pool was sourced only from getAlbumList(frequent), so the rotation
cursor walked the same eight top-played artists no matter how varied the
rest of the listening history was. Round-robin merge of mostPlayed,
recentlyPlayed and starred (dedup by artistId) means each mount can land
on a different listening *mode* — heavy rotation, current focus, or
explicit favorites — instead of stepping through the same top-played
sequence.

Pool size 8 -> 12 to let the cursor visit all three modes before
wrapping. Visibility guard widened so the rail still renders when the
server has no frequent-play data yet but starred or recent items exist.
Zero new API calls — all three lists are already in Home's initial
fetch.

* fix(home): drop orphan 3rd Because-card in 2-col range, keep all 3 stacked on mobile

auto-fit grid wraps to 2 cols between 696-1051px container width, which
left the third card alone on a second row at 1080p. Container query
hides the 3rd card only inside that 2-col band; on wider screens the
full 3-up row stays, on narrow viewports (single column) all three
cards stack vertically as expected.

* docs(changelog): Because-you-listened seed pool + 1080p layout polish (PR #493)

* docs(changelog): fold PR #493 refinements into the existing Because-you-listened entry

Drop the separate Changed section entry — the feature is in the same
1.46.0 release window as PR #489, so readers want a single description
of the final behaviour, not "added X, then changed X" for the same
release. PR reference becomes "PRs #489, #493".
2026-05-07 09:12:47 +02:00
Frank Stellmacher b01e76df9c fix(home): Because-cards — readable layout at 1080p / 3-up (#492)
At 1080p with three cards per row (~370 px wide) the previous layout
broke down:

- 200×200 cover left only ~150 px of text width after gap + padding,
  so titles truncated mid-word and the meta-pill wrapped vertically
  into a stack instead of staying on one line.
- The "·" separators in the meta vanished as soon as the pill wrapped,
  because the ::after lives inside the wrapping flex line.
- Albums without cover-art rendered the placeholder as an empty grey
  rect — visually broken next to neighbours that did have art.

Adjustments:

- Cover wrap 200 → 160 px. Buys 40 px of text width per card and brings
  cover/text proportions into balance.
- Meta-pill: inline-flex with width: max-content + max-width: 100% so
  the pill is content-fit when the meta line fits, capped at the
  available text width otherwise. Font 12 → 10 px, column-gap and
  padding tightened, flex-wrap kept (wraps to a second row if a server
  ever returns a really long meta string instead of clipping).
- Cover-art placeholder shows a centred Lucide Music icon at 30 %
  --text-primary alpha — same visual weight as a faded thumbnail
  instead of an empty rect.
2026-05-07 02:58:01 +02:00
Frank Stellmacher 38b89f9730 fix(theme): migrate persisted state from removed theme ids (#491)
PR #490 dropped five community themes (amber-night, ice-blue, monochrome,
phosphor-green, rose-dark). Existing users who had any of those selected
land on a non-existent data-theme attribute after the update — the
browser silently falls back to :root defaults and the picker shows the
old id as inactive in the list.

Add a Zustand persist `migrate` hook (version 1) that remaps the removed
ids to the closest surviving palette per family — gold for amber, carbon
grey for ice / monochrome, deep forest for phosphor green, sakura night
for rose. Applies to `theme`, `themeDay` and `themeNight` (theme
scheduler), so a scheduled night theme that was set to a removed id is
remapped too.

New installs are unaffected (migrate runs against persisted state only).
2026-05-07 02:46:25 +02:00
Kveld. f82f1be63a feat: redesigned community themes (#490)
* redesigned community themes

* fixed select arrow obsidian-black & violet-haze

* docs: CHANGELOG + Contributors entry for community themes redesign (PR #490)

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-07 02:43:14 +02:00
Frank Stellmacher d1ff2fab51 feat(home): Because you listened recommendation rail (#489)
* feat(home): "Because you listened" recommendation rail

New Home rail (under Recently Added, default on, toggleable in Settings →
Personalisation → Home Page) that surfaces 3 albums from artists similar
to one of your top-played artists. Anchor rotates per Home mount so a
different top-artist seeds the recommendations each visit; within each
anchor, both the similar-artist subset and the chosen album per artist
are randomised, so the same anchor returns different picks on subsequent
visits.

Card layout matches the regular Album cards' surface (--bg-card with
accent-tinted border + 1px inset top highlight) and gets the same
Play / Enqueue hover overlay buttons. Cover and meta scale via CSS only
— no infinite animations, no filter/blur/transform, no compositing
layers. Grid wraps below 3-up at <400px card width instead of shrinking.

API budget: one getArtistInfo2 + 6 parallel getArtist calls per Home
mount, both reusing the existing mostPlayed payload to derive the anchor
pool (no extra API call to find top artists). All 8 locales seeded.

* fix(home): ru plurals + per-server anchor + narrower card grid

- ru: add _few / _many for becauseYouLikeTracks (CLDR Russian needs
  4 forms — 3 треков was wrong, now 3 трека).
- Anchor rotation memory is now per-server. The localStorage key
  becomes psysonic_because_anchor:<serverId>; switching servers no
  longer aliases server A's rotation onto server B's pool.
- because-card grid minmax(400px, 1fr) -> minmax(340px, 1fr) so two
  cards fit side by side at typical sidebar-expanded widths instead
  of collapsing to a single card per row.

* docs: CHANGELOG + Contributors entry for Because-you-listened rail (PR #489)

* style(home): blurred cover backdrop + centred layout for Because-cards

- Each Because-card renders the album cover as a blurred, low-opacity
  full-bleed background layer behind the existing cover thumb and text.
  Resolved through useCachedUrl so the cache layer feeds it (same key
  as the thumbnail) instead of a fresh salted URL on every render.
- Card content (cover thumb + text block) now centred horizontally and
  vertically within the card; the meta line lives in a small pill that
  sits centred under the artist row.
- Text contrast halo and meta-pill background are theme-aware via
  color-mix on var(--bg-card) / var(--text-primary), so the same rules
  read on dark and light themes (was hard-coded rgba black before and
  smudged the type on Latte / Nord Snowstorm).
2026-05-07 02:28:58 +02:00
Frank Stellmacher 5b37ab70f1 perf(tracklist): drop now-playing pulse + EQ-bar animations (#488)
* perf(tracklist): replace animated EQ-bar + active-row pulse with static icon

The currently-playing track in any tracklist (AlbumDetail, ArtistDetail,
PlaylistDetail, Favorites, RandomMix) was rendered with two animations
on top of an already-busy DOM:

- `.track-row.active` ran `track-pulse` 3s opacity 1 → 0.6 → 1 infinite
  on the entire row subtree (title button, several Lucide icons, star
  rating SVGs, hover affordances). Opacity is a compositor property, but
  on WebKitGTK without compositing — Linux + NVIDIA proprietary +
  WEBKIT_DISABLE_COMPOSITING_MODE=1 — every animated row falls back to a
  full software repaint of the subtree per frame.
- The "now playing" indicator in the track-number cell was three
  `<span class="eq-bar">` siblings with `transform: scaleY()` keyframes
  (`eq-bounce`), each on its own delay/duration. Same composite story:
  three software-repainted layers per frame, every frame.

On AlbumDetail (long tracklist + cover-art header background + the
already-running WaveformSeek progress rAF in the player bar) the
combined cost held the WebProcess at ~80 % CPU with 1.2 GB RSS for the
entire duration of playback. CPU dropped immediately on pause/stop;
on Composers / Settings (no track rows) the symptom never appeared.
Profiler confirmed continuous Layout & Rendering work synchronised with
isPlaying, not the canvas itself.

Replace both animations with static visuals:

- `.track-row.active` keeps the `--accent-dim` background, drops the
  pulse animation entirely.
- The "now playing" indicator becomes a single Lucide `AudioLines` icon
  (four vertical bars of different heights — reads as an EQ icon, no
  animation, one SVG per active row instead of three animated spans).
  `.eq-bars` className now only sets the accent colour.

Cleanup: dead `@keyframes track-pulse`, `@keyframes eq-bounce`,
`.eq-bars.paused` rule, plus a duplicate `.eq-bar` block in theme.css
(with a `wave` keyframe that was being shadowed by components.css and
had no other consumers).

No behaviour change beyond removing the animation; the row is still
visibly the active one and the icon still marks the playing track.

* docs: CHANGELOG entry for tracklist animation perf fix (PR #488)
2026-05-07 01:57:14 +02:00
Frank Stellmacher 59744601d4 feat(composer): Browse by Composer page (issue #465) (#487)
* feat(composer): Browse by Composer page (issue #465)

New library section listing every artist credited as composer on at
least one track, with a detail page showing all works they're credited
on in that role. Targeted at classical-music libraries where the
"recording artist" tag carries the orchestra and the "composer" tag
carries Bach / Mozart / Chopin.

Hits Navidrome's native /api/artist?_filters={"role":"composer"} for
the listing and /api/album?_filters={"role_composer_id":"…"} for the
works grid — Subsonic getArtist only follows AlbumArtist relations and
returns 0 albums for composer-only credits, so the native API is the
only path that works. Requires Navidrome 0.55+ (uses
library_artist.stats role aggregation); on older / pure-Subsonic
servers the page shows a one-line capability banner.

- Two new Tauri commands: nd_list_artists_by_role +
  nd_list_albums_by_artist_role, generic over participant role so
  conductor / lyricist / arranger pages are trivial to add later.
- Composers grid: text-only compact tiles (name + participation count
  pulled from stats[role].albumCount). No avatars — composer libraries
  carry no useful imagery and the listing endpoint exposes no image
  URLs anyway.
- ComposerDetail: hero with Last.fm bio (via getArtistInfo2) plus the
  full work grid, with a graceful fallback when the artist has no
  external info synced.
- Sidebar entry default off (Feather icon) — opt-in for the niche
  classical use case.
- nd_retry backoffs widened from [500] to [300, 800, 1800] — helps
  every nd_* call survive intermittent TLS-handshake-EOF errors that
  some reverse-proxy setups produce when keep-alive pools churn.
- Distinguishes "server can't do this" (HTTP 400/404/422/501) from
  transient errors so the capability banner only fires when the server
  actually rejects the request shape; everything else gets a retry
  button.
- i18n in all 8 supported locales.

* fix(composer): address review feedback on detail page + role queries

- Re-fetch ComposerDetail when music-library scope changes; previously
  the album grid stayed stale until navigation while the list refreshed.
- Thread library_id through nd_list_artists_by_role and
  nd_list_albums_by_artist_role so role queries respect the active
  Navidrome library, matching the Subsonic musicFolderId already piped
  through libraryFilterParams().
- Fix CachedImage cache-key mismatch on ComposerDetail: a Last.fm header
  image was stored under the Subsonic cover-art key, aliasing cache
  entries and risking cross-source pollution.
- Consolidate the two contradictory composer-imagery comments in
  Composers.tsx into a single accurate one (the older one referenced an
  Images toggle that was never implemented).
- Align openLink toast duration with ArtistDetail (1500ms -> 2500ms).

* fix(composer): keep bio across scope changes, add share, degrade gracefully

Three remaining items from the latest review pass on the composer flow.

1. Bio survives a music-library scope change.
   The previous fix added musicLibraryFilterVersion to the load effect,
   but that effect also did setInfo(null) while the getArtistInfo effect
   still depended on [id] alone — so a scope bump on the open page
   wiped the bio without re-fetching it. Move the info reset into the
   bio effect (keyed on id) and out of the load effect: the album grid
   still refreshes on scope change; the Last.fm header image and
   biography survive untouched, since both are library-independent.

2. Composers join the share pipeline as a first-class entity kind.
   Extend EntityShareKind with 'composer' (and isEntityKind), branch
   applySharePastePayload to validate via getArtist (same id pool) and
   navigate to /composer/:id, and wire a Share button into
   ComposerDetail. A pasted composer link now opens the composer view
   instead of the artist view, matching what was copied. i18n added in
   all 8 locales (sharePaste.composerUnavailable, openedComposer;
   composerDetail.shareComposer, unknownComposer).

3. Partial server failure no longer hides the works.
   If getArtist rejects but ndListAlbumsByArtistRole succeeds, the page
   used to show full "not found" despite having data to display. Switch
   the not-found gate to require both empty (`!artist && !albums`) and
   render a degraded header (placeholder name, no Wikipedia / favourite
   / share / Last.fm image) when only metadata is missing.

* fix(composer): right-click share copies a composer link, not an artist link

The context menu opened from a composer card / row uses type='artist'
because every composer-action (radio, favourite, rating, add-to-playlist)
is identical to the artist counterpart — they share an id space and a
backend representation. Sharing was the one exception: the "Share Link"
entry produced a 'psysonic2-' string with k='artist', so a paste opened
/artist/:id even though the user came from /composers.

Add an optional shareKindOverride to openContextMenu (default: undefined,
preserves existing behaviour) and have the artist-typed branch consult
it when calling copyShareLink. Composers.tsx now passes 'composer' on
both right-click sites; nothing else changes downstream because the
override only affects the share kind.

* polish(composer): show Last.fm avatar even without server metadata

Two minor follow-ups from the latest review.

- ComposerDetail: drop the `&& artist` guard on the header-avatar render
  path. info?.largeImageUrl can resolve through getArtistInfo(id) without
  ever needing the SubsonicArtist record, so the previous gate hid a
  perfectly good Last.fm portrait whenever getArtist failed but the
  bio fetch succeeded. Replace artist.name with displayName so the
  alt / aria-label degrade to the localised "Composer" placeholder
  instead of empty strings.
- copyEntityShareLink: doc comment now mentions composer alongside
  track / album / artist.

* fix(composer): derive Last.fm cache key from route id, not from artist record

Follow-up to the previous polish: the avatar render path no longer
requires `artist` to be populated, but the cache-key gate still did. So
when getArtist failed but getArtistInfo returned a Last.fm portrait, the
key fell through to coverKey — which is empty without an artist record,
re-creating the very aliasing bug the earlier Subsonic-vs-Last.fm fix
was meant to close.

Switch the Last.fm branch to the route id (same id namespace as the
SubsonicArtist record), so the key stays stable whenever Last.fm art is
shown, independent of getArtist succeeding.

* docs: CHANGELOG + Contributors entry for composer browsing (PR #487)
2026-05-07 00:36:09 +02:00
cucadmuh c83447ebd2 fix(player): reset WaveformSeek interpolation anchor on resume (#486)
The interpolation effect unmounts while paused, so progressAnchorRef.atMs was
never refreshed. The first tick after play added the entire pause duration to
elapsedSec and overshot the playhead until the next transport heartbeat.
Re-anchor from getPlaybackProgressSnapshot() when the effect starts.
2026-05-06 21:48:06 +00:00
Frank Stellmacher e215694301 feat(help): rewrite Help page — trimmed Q/A, 10 sections, live search (#485)
* feat(help): rewrite English Q/A entries — trim, consolidate, refresh

The Help page had grown to ~50 entries over time, with several that
the UI itself answers (double-click to play, click the cover for
fullscreen, click the repeat button to cycle, …) and other groups
that were better folded into a single answer (rating + Skip-to-1★,
Internet Radio basics + supported formats, Device Sync overview +
filename template + cross-platform behaviour, …).

This pass:
- drops obviously-redundant entries (q4, q7, q8, q11, q22, q24, q25,
  and the trivial Settings → X pointers q12, q13, q15, q32, q42, q43)
- consolidates the natural groupings (q5+q31, q37+q38+q39, q53+q54+q55,
  q34+q35+q47, q26+q27+q28, q12+q41, q56+q57)
- adds entries for features that did not exist yet when the previous
  Q/A list was written: Orbit (Listen Together), Magic Strings
  sharing, LUFS Smart Loudness Normalization, Mini Player + Floating
  Player Bar, Smart Playlists, Track Preview, Search and Advanced
  Search, Statistics, Tracks library hub, Genre tag-cloud browser,
  Discord Rich Presence, Bandsintown tour dates, Multi-select +
  Shift-click range selection, Sidebar / Home / Artist Page
  customization, Sleep Timer, Open Source Licenses

Result: 45 focused entries across 10 sections (Getting Started /
Playback & Queue / Audio Tools / Library & Discovery / Lyrics /
Sharing & Social / Personalization / Power User / Offline & Sync /
Integrations & Troubleshooting), each one answering something the UI
does not already answer at a glance.

* feat(help): restructure into 10 sections with live search

Page is now organised into ten focused sections (Getting Started,
Playback & Queue, Audio Tools, Library & Discovery, Lyrics, Sharing
& Social, Personalization, Power User, Offline & Sync, Integrations
& Troubleshooting) each rendered as its own column-friendly accordion
group with a Lucide icon.

A search input lives in the page header. Typing filters every Q+A
pair across all sections by case-insensitive substring; sections that
end up empty are hidden, matched items are auto-expanded so the user
sees the answer without having to click each result, and a "no
results" empty state appears when the query matches nothing. Clearing
the input restores the manual accordion behaviour. An × button next
to the input clears the query in one click.

CSS uses dedicated `.help-search`, `.help-search-icon`,
`.help-search-input`, `.help-search-clear` rules instead of leaning
on the global `.input` class — the latter brought its own focus-ring
styles that doubled with the wrapper border. Focus state highlights
the wrapper border to `--accent` via `:focus-within`.

* i18n(help): translate the new Help page to 7 locales

Updates de, fr, nl, zh, nb, ru, es to match the new English Q/A
structure (45 entries across 10 sections, plus the live-search
labels: title, searchPlaceholder, noResults).

DE / FR / NL / NB / ES were translated directly. RU and ZH are
structurally correct but written at machine-translation quality;
both could use a pass from the original locale maintainers
(@cucadmuh for RU, @jiezhuo for ZH) — none of the wording is
load-bearing for the i18n keys, so the page renders correctly today
and refinements can land as follow-up touch-ups without coupling.

* docs: changelog + contributors for PR #485

Adds the v1.46.0 "Changed" entry and the Psychotoxical contributors
line for the Help page rewrite.
2026-05-06 19:28:47 +02:00
Frank Stellmacher 43d75e744b feat(selection): Shift+Click range selection on grid pages (#484)
* feat(selection): add useRangeSelection hook with Shift+Click range support

Reusable hook for multi-select state across pages that show grids of
items the user can pick. Tracks the selected ID set, a click anchor,
and exposes a `toggleSelect(id, { shiftKey })` callback:

- Plain click → toggles that item and moves the anchor to it.
- Shift-click on a second item → adds every item between the anchor
  and the click target (inclusive) to the selection. The anchor moves
  to the shift-clicked item so the next shift-click extends from
  there.

Range expansion follows the items array passed to the hook, so the
caller controls the user-visible order (filtered + sorted list, not
the raw upstream array).

Implementation note: the anchor ref is snapshotted *before* the state
updater runs and written *after* it. React 18 strict mode invokes
state updater functions twice in dev to surface side effects, so any
ref mutation inside the updater would taint the second invocation and
the replay would miss the range branch.

* feat(selection): adopt Shift+Click range selection on grid pages

Wires `useRangeSelection` into the four pages that ship a multi-select
mode on top of card grids:

- Albums (passes the filtered/sorted `visibleAlbums` so range follows
  the order the user actually sees)
- RandomAlbums
- NewReleases
- Playlists

`AlbumCard.onToggleSelect` is extended to forward `{ shiftKey }`, and
the card's onClick handler reads `e.shiftKey` from the React event
and threads it through. The Playlists grid uses an inline onClick on
the card div and was updated the same way.

User-visible behaviour: in selection mode, click an item then
shift-click a later item — every item between them gets selected.
Existing single-toggle behaviour is unchanged when no shift key is
held.

* docs: changelog entry for PR #484

Logs the Shift+Click range selection on grid pages under
v1.46.0 "## Changed".
2026-05-06 17:51:25 +02:00
Frank Stellmacher 6c1deeeb7f feat(most-played): quick actions, real context menu, prominent plays badge (#482)
* feat(most-played): quick actions, real context menu, prominent plays badge

Three UX refinements on Settings → Most Played, in response to user
feedback:

* **Quick actions on each album row** — Play and Enqueue buttons that
  reuse the same logic as AlbumCard (Play kicks the existing
  `playAlbum` fade-out flow; Enqueue fetches the album and appends its
  songs to the queue). Always visible, not hover-gated.
* **Real context menu** on right-click — replaces a hidden direct
  `playAlbum` action with the standard `openContextMenu(...)` flow
  used elsewhere in the app, so right-click on an album row now opens
  the full album context menu (Play / Add to queue / Play next /
  Add to playlist / Go to artist), and right-click on a Top Artists
  card opens the artist context menu.
* **Plays badge next to the album title** — replaces the small
  right-aligned plays count that was easy to miss. Each row now shows
  a localized pill (`11 plays` / `11× gespielt`) right next to the
  album title, since the play count is the central datum on this
  page.

CSS: new `.mp-album-name-row`, `.mp-album-plays-pill`,
`.mp-album-actions` and `.mp-album-action-btn` rules; the unused
`.mp-album-plays` block and its right-most grid column were removed.

* docs: changelog entry for PR #482

Logs the Most Played quick-actions / real context menu / prominent
plays badge changes under v1.46.0 "## Changed".
2026-05-06 16:55:33 +02:00
Frank Stellmacher ebce53f8a7 fix(sidebar): centre Playlists icon and unify hover hitbox in collapsed mode (#481)
* fix(sidebar): centre Playlists icon and unify hover hitbox in collapsed mode

The Playlists nav entry had its own special render path with a wrapper
div, header-row and `flex: 1` main link to fit the expand-toggle
button. Those elements remained active in collapsed mode too, where
`padding-right` on the header-row and `flex: 1` on the link made the
icon sit off-centre and gave the row a wider hover hitbox than every
other collapsed sidebar item.

Hoist the `isCollapsed` check above the playlists special-case so that
in collapsed mode Playlists renders through the same plain `<NavLink>`
branch as Artists / Albums / Favorites / etc. The expanded-mode
treatment (wrapper, header-row, expand-toggle, nested playlist list)
is unchanged.

* docs: changelog entry for PR #481

Logs the collapsed-sidebar Playlists icon centring fix in v1.46.0
"## Fixed".
2026-05-06 16:12:50 +02:00
cucadmuh b084e96c1f fix: prune stale analysis queues and cap loudness backfill window (#480)
* fix(analysis): prune stale backfill jobs and limit prefetch window

Drop pending backfill and cpu-seed jobs that are no longer in the active playback queue, and add debug counters for pruned work. Limit loudness backfill scheduling to the current track plus the next five tracks to prevent runaway queue growth in dev sessions.

* chore(analysis): remove unused loudness prefetch parameter

Drop the now-unused incoming-tracks parameter from the loudness prefetch helper and update internal call sites to match the current queue-window scheduling logic.

* docs(changelog): document analysis queue control fix (#480)

Add a short 1.46.0 Fixed entry describing stale backfill pruning, the current+5 loudness backfill window cap, and debug prune counters for diagnostics.

* docs(contributors): add cucadmuh entry for PR #480

Logs the analysis-queue prune + loudness backfill window cap in the
Settings → System → Contributors list.
2026-05-06 16:42:48 +03:00
Sayykii dc35f53674 feat(artist): group albums by release type on artist page (#471)
* feat(artist): group albums by release type on artist page

Uses the releaseType field to group albums/releases into sections like Albums, Compilation, Live, etc.
If there's no release type it falls back to normal view

* feat(artist): i18n release-type group labels

* fix(artist): deterministic release-type group order

* refactor(artist): replace inline styles with CSS classes

* i18n(artist): translate release-type labels in remaining 7 locales

Sayykii's `releaseTypes` namespace was added to en.ts only. Fills in
de, fr, nl, zh, nb, ru, es with the same 8 keys (album, ep, single,
compilation, live, soundtrack, remix, other) so users on non-English
UIs see translated section headers on the artist page instead of the
raw title-cased fallback.

* docs: changelog + contributors for PR #471

Adds the v1.46.0 "Added" entry and bumps Sayykii's contributors line
for the artist-page release-type grouping.

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-06 14:47:05 +02:00
Frank Stellmacher 8692e50603 feat(settings): Open Source Licenses section in System tab (#477)
* feat(licenses): tooling and initial data generation

Adds the maintainer-only generator that produces src/data/licenses.json:
- src-tauri/about.toml + about.hbs: cargo-about config + handlebars template
  for the Rust-side license enumeration
- scripts/generate-licenses.mjs: orchestrator that runs cargo-about and
  license-checker-rseidelsohn (via npx, no devDep), merges the outputs into
  a single per-crate JSON with full license texts, and writes the result to
  src/data/licenses.json

The script is invoked directly with `node scripts/generate-licenses.mjs` —
no npm script wrapper on purpose, since adding one to package.json would
trigger the nix-npm-deps-hash-sync workflow on every push.

Initial generation covers 575 cargo crates + 71 npm packages (646 entries
total, all with full license text bundled, ~1.4 MB JSON).

* feat(licenses): Settings panel UI

Adds a new Open Source Licenses section under Settings → System,
sitting below Contributors. Components:

- LicensesPanel.tsx: search input, curated highlight block of ~10 key
  dependencies (Tauri, React, rodio, symphonia, etc.), TanStack-Virtual
  list of all 600+ entries
- LicenseTextModal.tsx: full-screen-ish modal showing the bundled license
  text plus name/version/license-id badges + repository link
- licensesData.ts: lazy dynamic-import loader (Vite emits the JSON as a
  separate chunk, so the heavy ~1.4 MB payload is only loaded when the
  user actually opens the panel — no runtime fetch, the data is fixed
  into the build artifact)

The panel registers itself in the Settings in-page search index under
the System tab.

* feat(licenses): i18n in 8 locales

Adds the `licenses` namespace (title, intro, highlights, search
placeholder, no-results, loading / load error, no-license-text,
view-source, total line, generated-at) across en, de, fr, nl, zh, nb,
ru, es. License names themselves (MIT, Apache-2.0, GPL-3.0, …) stay
universal and are rendered as-is.

* docs(release): document licenses regeneration step

Adds a Step A.3 to the release SOP describing the maintainer-only
`node scripts/generate-licenses.mjs` workflow, the cargo-about
prerequisite, and explicitly notes why no npm script wrapper exists
(would trigger the nix-npm-deps-hash-sync workflow).
2026-05-06 14:17:13 +02:00