* fix(api): align native client UA with the WebView to collapse the duplicate Navidrome session
* docs(changelog): duplicate Navidrome session fix (#1322)
* perf(themes): gate inactive injected theme styles out of style matching
Inactive installed themes' <style> elements get media="not all", so the
browser skips their rules during style recalculation entirely; only the
active theme and the scheduler's day/night slots stay live. With many
installed themes every recalc (hover, playing-state flips) walked every
theme's rules. Switching only flips the media attribute in the same
effects flush that sets data-theme - no re-inject, no flash.
* docs(changelog): add 1315 theme style-matching entry
* refactor(playlist): extract resolvePlaylistTracks shared helper
Move the offline- and active-library-scope-aware playlist track resolution out of the Playlists overview Play handler into a reusable helper, so the overview and the playlist context menu share one resolution path instead of diverging.
* feat(playlist): add play and queue actions to the playlist card context menu
Right-clicking a playlist card now offers Play next and Add to queue alongside Play now, matching the album card. All three resolve tracks through resolvePlaylistTracks (offline- and active-library aware), so Play now becomes consistent with the queue actions and works offline. Drops the now-unused playPlaylistById.
* docs(changelog): add playlist card queue actions entry (#1307)
* fix(library): sort albums by the artist the row actually shows (#1217)
The album browse ordered by MAX(t.artist) -- the raw track artist -- while the
row mappers derive the displayed artist with pick_album_group_artist, which
prefers the album artist. On an album with featured guests the two differ
("Alpha feat. Zulu" vs "Alpha"), so the album sorted under a name the user never
sees and fell out of its artist's year run, sometimes landing behind an entirely
different artist.
Order by the same rule the row displays, via a shared SQL expression
(sql_display_artist_from) that both query shapes feed their own columns into:
aggregates for the grouped album browse, projected columns for the multi-library
dedup path.
That dedup path used to build its ORDER BY by string-replacing MAX(t.x) out of
the grouped SQL, which only held while every sort key was a bare aggregate and
would have silently mangled the new expression -- it now builds directly from
its own columns.
Regression test uses a featured-guest album and a second artist that sorts
between the two spellings; it fails against the old expression (album lands
last) and passes with the fix.
* docs(changelog): add entry for PR #1292
* fix(library): bind the album sort to the album aggregates in the scoped browse
The scoped browse (scope_merge) feeds the same ORDER BY into three query shapes.
Two of them GROUP BY t.album_id and selected the sort columns unaliased, so
`artist` / `album_artist` in the ORDER BY resolved to bare table columns -- taken
from an arbitrary row of the group -- rather than to the MAX() aggregates the row
mapper reads. The featured-guest defect (#1217) therefore survived on that path:
sorting could still key on a track credit ("Alpha feat. Zulu") instead of the
album artist.
Alias the sort columns so the ORDER BY binds to the aggregates. Adds a scoped
regression test that fails against the previous expression (featured album lands
behind a different artist) and passes now.
* fix(library): give the scoped GROUP BY browse its own album sort key
Review F1. The scoped browse ran `GROUP BY t.album_id` but received the
dedup shape's ORDER BY, whose display-artist key is a CASE over bare
`artist` / `album_artist`.
SQLite substitutes a result alias into ORDER BY only when the whole term
is a plain identifier: `ORDER BY artist COLLATE NOCASE` does bind to
`MAX(t.artist) AS artist`, but the same name inside a CASE resolves
against `track` instead — and a bare column in a grouped query is read
from an arbitrary row of the group. Aliasing the select list, the first
attempt at this, therefore never fixed the CASE form: an album whose
tracks carry `album_artist` unevenly sorted under whichever row SQLite
happened to pick. Verified both halves against SQLite directly.
Split the parameter: the two `GROUP BY t.album_id` branches now take a
grouped key (aggregates inside the CASE), and only the dedup subquery,
which really does project plain columns, takes the deduped one. The
genre and scope-list callers pass plain-identifier keys, which alias-
resolve correctly in either shape, so they hand the same string to both.
Tests (F2, F3):
- Deterministic guard on the grouped key — no bare column may survive in
it. The behavioural tests can pass by luck when the arbitrary row is a
favourable one; this one cannot.
- Multi-track album with a sparse `album_artist`, the shape the previous
single-track tests could not reach.
- SQL/Rust parity for the aggregate form of the display-artist rule,
over multi-row groups, guarding drift from `pick_album_group_artist`.
* fix(settings): revalidate the registry behind the theme credits
Credits read the theme registry through the plain TTL cache, so a copy
up to 12 hours old was served without ever touching the network. That is
fine for browsing the store, but Credits attributes work to a person: an
author whose handle is corrected upstream stayed mis-credited until the
cache aged out, and Credits has no refresh control of its own.
Add `revalidateRegistry` — stale-while-revalidate. The cached copy paints
immediately (still offline-safe), a forced fetch runs in the background,
and the list updates only when the registry actually changed.
* docs(changelog): note theme credits revalidation fix (#1302)
Modal dialogs now carry an accessible name: the dialog is linked to its title via aria-labelledby, with a per-instance id so several open dialogs cannot collide. Adds a Modal test suite covering the accessible-name contract, id uniqueness, and close behaviour.
* fix(ui): reserve shadow room in album rails so themes need no overflow hack
A horizontal album rail scrolls, so it clips — `overflow-x: auto` makes
`overflow-y` compute to `auto` as well — and an outer card shadow gets
cut off at the edges. The default theme never hit this (rail cards use
inset shadows only), but a theme with a real drop shadow did, and the
only way out was overriding `overflow` on `.album-grid`. That removes
the scroll container the nav arrows drive, so the arrows go dead.
Reserve the room inside the clip box instead: pad the rail and cancel
the padding with a matching negative margin, so the content box stays
exactly where it was. The amount is a `--rail-shadow-room` token, so a
theme with a larger shadow or glow raises that instead of touching
`overflow`.
* docs(changelog): note album rail shadow room fix (#1300)
* feat(discord): allow the server cover source in the store again
DiscordCoverSource gains 'server' back. The rehydrate migration that
forced any persisted 'server' preference to 'none' is dropped — that
coercion only made sense while the source didn't exist.
* feat(discord): resolve server covers via credential-free album-info URLs
Add a resolver that fetches cover art for the Discord 'server' source
through the standard Subsonic getAlbumInfo2 endpoint, never through an
authenticated getCoverArt URL (that was the leak fixed in #1246).
- sanitizeDiscordCoverUrl rejects anything unfit to publish: non-https,
embedded userinfo, auth-shaped query params (u/t/s/apiKey/jwt/token/...),
LAN/loopback hosts
- resolveServerCoverForDiscord takes only an album id and a share-base
string, never a server profile or credentials, and session-caches
results (including negative ones) per album
- rewrites a LAN-scoped album-info response to the profile's public
share address, keeping path + query, so the app being connected over
LAN doesn't hide an otherwise-public /share/img/<jwt> link
- discordPresence.ts wires the 'server' branch in with a staleness
guard so a slow resolve can't overwrite a newer track's presence
* feat(discord): add the server option back to the cover-source picker
Three-way segmented control again: none / server / apple. The cover
description now discloses that server covers reveal the server's
public address to anyone viewing the presence (no credentials).
Restores discordCoverServer in all 14 locales — the Italian locale
already carried an unused copy of the key from before it existed at
removal time, now wired up instead of left dead.
* fix(discord): reject non-publishable image URLs before they reach Discord
is_publishable_image_url is a backstop applied to every artwork_url
(cover_art_url param and the iTunes result alike) right before it can
become a large_image asset: https only, no userinfo, no auth-shaped
query params. This is the layer a future frontend refactor can't
silently bypass — the failure mode that turned the original
credential-free server-cover design (#462) into the leak fixed in
#1246 was exactly that kind of regression, on the frontend only.
* docs(changelog): note Discord server cover source restoration (#1299)
* fix(discord): close the LAN-host gap in the Rust publish backstop
is_publishable_image_url checked scheme/userinfo/credential params but
not host locality, despite its doc comment claiming parity with the TS
sanitizer's isLanUrl check — a self-review found the gap. Reuses
psysonic-core::log_sanitize's existing is_lan_host (now pub) instead
of a second hand-written LAN-detection copy.
* fix(discord): don't silently resurrect a pre-#1246 'server' preference
The rehydrate migration that forced any persisted 'server' cover
source to 'none' was dropped in the initial revival commit, which
meant a user skipping straight from a pre-#1246 build to this one
would have gotten it silently reactivated without ever seeing the new
opt-in disclosure. Restored as a one-time, sentinel-gated migration:
it still catches that stale value exactly once, but never coerces a
deliberate post-revival choice back.
* fix(discord): harden the server-cover sync against races and cross-server tracks
- Staleness guard on the async resolve now also rechecks
discordRichPresence/discordCoverSource, not just the track id, so a
slow resolve can't revive presence after it was disabled or the
cover source was switched away from 'server' while in flight.
- Skip server-cover resolution (fall back to the app icon) when the
playing track isn't from the active server — getAlbumInfo2 always
queries the active server, so a mixed-server queue could otherwise
ask the wrong server for an album id.
- The change-detection gate now also reacts to the active server
profile's share-base changing (e.g. adding a public address), not
only to track/play-state/cover-source/template changes.
- Server profile/shareBase is now computed once per sync and reused,
instead of a second useAuthStore.getState() call inside the branch.
* fix(discord): preserve reverse-proxy paths and TTL the server-cover cache
- rewriteOriginToShareBase only swapped protocol/hostname/port, so a
server reachable behind a reverse-proxy subpath (shareBase carrying
a path prefix) lost that prefix and produced a 404ing URL. The
prefix is now preserved.
- serverCoverCache entries (including negative ones) no longer live
forever — a 1h TTL, matching the existing Rust iTunes-artwork cache,
means a transient resolve failure doesn't hide an album's cover for
the rest of the session.
* fix(discord-banner): give the icon an explicit size so it does not blow up on Windows
The icon carried a viewBox but no width/height, and the .discord-banner-icon
class it uses had no CSS rule at all -- so the SVG had no intrinsic size. WebKit
happened to render it small; Chromium (WebView2, i.e. Windows) fell back to the
300x150 default replaced-element size, so the icon swallowed the bar and pushed
the message out of the row.
Size it explicitly on the element (correct even before the stylesheet applies)
and add the missing rule with flex-shrink so it cannot be squeezed either.
* docs(changelog): add entry for PR #1289
* docs(changelog): place the #1289 entry in PR order
* feat(player-bar): persistent shuffle mode
Shuffle reorders the queue itself -- the tracks after the current one -- and
remembers the order it came from, so switching it off restores that order. The
flag and the remembered order are persisted together: shuffle survives a
restart, so the order it can be undone to has to survive with it.
Keeping the list untouched and only *playing* in a hidden random order was
rejected: "what plays next" is derived from list order in four places (manual
next, gapless successor, chain preload, crossfade/AutoDJ plan), the engine is
handed the next track ~30s ahead with no way to take it back, and the server
play-queue and Orbit guests only ever see the list order -- a hidden permutation
would be invisible to them and lost on any other client.
The toggle lives with the other queue mutations (it pushes an undo snapshot and
syncs to the server like they do); the pure helpers stay in their own module,
free of store imports, so no new import cycle is introduced.
Restoring is id-based and total: duplicate track ids each keep their copy, rows
enqueued while shuffle was on go to the end (they had no original position), and
no row is ever lost or duplicated.
* i18n(player): shuffle on/off state and player bar item label in all 14 locales
* docs(changelog): add entry and credit for PR #1288
* feat(player-bar): configurable stop button, album line and reorderable actions
Extends the existing player-bar layout store instead of adding a second one.
- 'stop' becomes a layout item, so the stop button can be hidden. It lives in a
'transport' zone: visibility only, no reordering -- the play button is a
centred special case and shuffling controls around it would fight the adaptive
small-window layout. Hiding it leaves no dead end, since the primary button
already acts as stop while previewing.
- Track info gains an optional album line under the artist, off by default and
suppressed for radio and previews, which have no album.
- The right-hand action buttons are drag-reorderable via the shared
useListReorderDnd primitive. Moves resolve by stable id against the full list,
so the zone filter that decides which rows render cannot desync the reorder.
- The section is no longer gated behind Advanced.
Rehydrate keeps older stored layouts working: items added later ('stop') are
appended with their default instead of silently disappearing.
* i18n(settings): player bar constructor strings in all 14 locales
* docs(changelog): add entry and credit for PR #1287
* fix(music-network): surface the underlying cause of a connect NETWORK error
NETWORK is the transport catch-all: a DNS failure, a TLS handshake broken by
a proxy or AV, a timeout and an unrecognised provider API error all collapse
into it. The connect form rendered only the translated string, so the cause
never reached the user or a bug report -- the same wire, transport and
token-poll strategy work for one provider and fail for another with no way to
tell why (#1283).
Add errorDetail(), which carries the transport message for NETWORK only
(capped), and append it to the message the connect form shows.
* docs(changelog): add entry for PR #1285
* fix(music-network): make the connect error line selectable so it can be copied
* fix(servers): probe gated servers over native reqwest so custom headers connect
Adding a server behind a header gate (Cloudflare Access, Pangolin service
tokens) failed at the connect step (#1216). The connect probe ran in the
WebView (axios); a non-safelisted header such as Authorization / CF-Access-*
makes the browser send a CORS preflight OPTIONS first, and that preflight
carries no token — so the gate rejects it and the real request never leaves.
Streaming and covers already worked because they go through Rust (reqwest),
which never preflights.
Route the header-bearing connect probe through a new Tauri command
`probe_server_connection` that runs the Subsonic ping over native reqwest with
the per-server header context (endpoints + apply rule), reusing the same
resolver as the data plane. Header-less servers keep the lightweight WebView
path unchanged.
- Rust: add `probe_server_connection` (+ `ServerProbeResult`) in app_api/network,
register in collect_commands!/generate_handler!, regenerate specta bindings.
- Frontend: `pingWithCredentialsForProfile` calls the command when the profile
has custom headers; add `serverHttpContextWireForProbe` helper.
- Tests: SubsonicClient sends the gate header on ping via http_context (and
misses the gated matcher without it); frontend probe routing + WebView
fallback; result mapping unit tests.
* docs(changelog): note gated-server connect fix (#1272)
* fix(servers): keep add form open and surface the reason when connect fails
The add-server flow closed the form the instant you pressed Add and, on
failure, left only a small status dot — so a bad password, a rejected gate
header, or an unreachable host all looked the same ("nothing happened").
- The connect probe now returns a short failure reason (the server's own
error message, an HTTP status, or a transport error) via ServerProbeResult
and the axios fallback; no secrets or header values are included.
- ServersTab keeps the Add form open until the connect test succeeds and
shows the reason in a toast on failure (edit flow surfaces it too).
- AddServerForm validates the server address and username before probing,
with clear per-field messages instead of a silent no-op.
- New i18n keys (serverUrlRequired, serverUsernameRequired,
serverConnectFailedReason) added across all shipped locales.
* fix(servers): route all WebView Subsonic REST through native proxy for gated servers
Once a header-gated server (Cloudflare Access, Pangolin, …) connected, every
view that still fetched over axios in the WebView came up empty — Main stage,
New Releases, Random Albums, Statistics, search, genres — because a
non-safelisted gate header makes the WebView send a CORS preflight the gate
rejects. Only the Rust-routed paths (streaming, covers, ping) worked.
- Add a generic `subsonic_proxy_request` Tauri command backed by
`SubsonicClient::send_raw`: it runs the request natively (no preflight),
applies the per-server gate header via ServerHttpContext, and returns the
untouched JSON body for the WebView to parse as it does an axios response.
Supports GET and form-POST (OpenSubsonic formPost) with a clamped timeout.
- `api`, `apiWithCredentials`, `apiPostFormWithCredentials` (and thus
`apiForServer`/`apiPostFormForServer`) now switch to the native proxy exactly
when a request would carry gate headers; header-less servers keep the
lightweight WebView axios path unchanged.
- Tests: wiremock coverage for `send_raw` (GET gate header + form-POST body);
frontend routing tests that gated `api()` calls hit the proxy and header-less
ones stay on axios; updated the OpenSubsonic extensions test for the new path.
* fix(servers): apply gate header to media paths via URL fallback
Streaming, prefetch and artist-info fetches returned 403 on a gated
server: apply_for_http_url resolved custom headers strictly by
server_ref and attached nothing when the caller's ref (e.g. the audio
engine's playback server id) didn't match the registry key. Add a
resolve_context helper that falls back to matching the request URL
against the server's registered endpoints — the same fallback covers
and Navidrome browse already relied on — and route the audio engine,
apply_for_http_url and apply_optional_registry_headers through it.
Endpoint matching only hits a configured gated server and still honours
the apply-to LAN/public rule, so non-gated servers stay untouched.
Also pool the native proxy's reqwest clients by timeout bucket so the
extra gated-server browse traffic reuses keep-alive connections instead
of opening a fresh pool per request (which surfaced as spurious
timeouts / 499s on fast endpoints).
* fix(servers): register gate headers before probe/bind so native paths work
Root cause of the persistent 403s behind a header gate: the native
ServerHttpRegistry was populated only at the end of bindIndexedServer,
after ensureConnectUrlResolved + the bind session. When that probe was
slow, hung, or reported the server briefly offline, the sync never ran,
leaving the registry empty — so every Rust-initiated request (stream,
cover, prefetch, fanart getArtistInfo2, Navidrome /auth/login) resolved
no header and the gate returned 403. The inline-context proxy path kept
working, which is why browse succeeded while media/covers failed.
Register the header context up front: before the reachability probe in
bindIndexedServer, and authoritatively for all servers at the start of
bootstrapAllIndexedServers (once React has mounted and IPC is ready).
Add concise sync/sync_all diagnostics (endpoint URLs + header count,
never values) so gated-server registration is observable in logs.
* fix(servers): retry gated-server covers that 403'd during header-registry gap
Covers fetched while the native gate-header registry was momentarily empty
(e.g. a dev restart before the header sync landed) got a 403, which the cover
fetcher classified as a permanent "cover missing" and wrote a 30-minute
`.fetch-failed` marker for — so on a gated server most artwork stayed blank
long after the gate started answering 200.
- Treat a gate-style 401/403 (plus 408/425/429) on cover art as a transient,
retryable hiccup rather than a permanent miss, so the short retry loop can
ride out a brief registry gap. Genuine 404/410/400 stay permanent.
- On (re)bind of a gated server, once its header is registered, clear that
server's stale `.fetch-failed` markers and kick a backfill pass so the
covers re-download — same rationale as the URL-change retry.
* refactor(servers): funnel gate-header application through one helper
Collapse the remaining bespoke header-application variants onto the single
`apply_optional_registry_headers` / `resolve_context` entry point so gated-server
header logic lives in exactly one place:
- cover_cache fetch and Navidrome `/auth/login` used older
`apply_for_http_url` / `get_for_server_url` + `apply_server_headers_for_http_url`
combos; both now call the shared helper.
- The audio engine's `apply_playback_request_headers` delegates to it instead of
re-implementing the resolve+apply.
- Drop the now-unused `ServerHttpRegistry::apply_for_http_url` /
`apply_for_base_url` methods.
Behaviour is unchanged: a non-gated server (no registry match) leaves every
request untouched, exactly as before. This is purely removing duplicate ways to
do the same thing so new native paths have one obvious hook.
* chore(servers): drop server-http registry debug logging
Remove the `[server-http] sync/sync_all` eprintln diagnostics added while
tracing the header-registry timing bug. They printed to stderr on every sync
(startup + each persist-rehydrate) and echoed endpoint URLs; the behaviour is
verified and covered by tests now, so the noise is no longer warranted. The
dev-gated `[connect-probe]`/`[subsonic-proxy]` failure logs stay.
* docs(changelog): point gated-server entry at PR #1273
* fix(servers): await gate-header sync before probe/bind
bindIndexedServer registered the per-server gate headers with a fire-and-forget
`void syncServerHttpContextForProfile(...)`, so a direct bind (add / enable a
single server) could run the native reachability probe and bind session while
the header IPC was still in flight — the registry was empty and native paths
403'd behind the gate, the exact race this branch fixes. Await the sync before
probing/binding; the stale-cover retry stays off the critical path.
* fix(servers): reclaim LAN from a sticky public endpoint
For a dual-address profile, the first endpoint that answered after launch stuck
for the whole session: a public sticky endpoint was always tried first and kept
answering, so the LAN-first order was never re-run and the connection never
upgraded back to LAN. pickReachableBaseUrl now makes a short, no-retry, bounded
quick-probe of the higher-priority LAN endpoint before honouring a public sticky
entry, so a laptop returning to the LAN upgrades on the next reachability tick
while staying remote costs only one bounded probe (never the full retry cushion).
* fix(servers): refresh LAN/public badge on active-server switch; credit #1273
The connection badge read the endpoint kind from the last probe and never
re-probed when the active server changed, so switching between a LAN-only and a
public server left the badge stuck on the old server's classification until the
120-s tick. useConnectionStatus now resets the kind and re-probes when
activeServerId changes (mount still deferred to the polling effect). Also adds
the settings credits line for the gated-server work (PR #1273).
* fix(servers): LAN reclaim uses the probe's own timeout, not a 3s race
The reclaim quick-probe raced pingWithCredentialsForProfile against a fixed 3s
timer, but the underlying probe (native reqwest for gated servers, axios
otherwise) runs on a 15s timeout — so a LAN endpoint answering between 3s and
15s was wrongly treated as unreachable and the session stayed pinned to the
public sticky endpoint. Reclaim now does a single, no-retry probe on the LAN
endpoint using the normal ping timeout: a slow-but-reachable LAN still upgrades,
while a dead one still costs only one attempt (not the full retry cushion)
before falling through to the sticky sequence.
* fix(lyrics): read SYNCEDLYRICS from the concrete Vorbis comment block
Embedded lyrics were never found on a FLAC/Ogg file whose only lyrics field is
`SYNCEDLYRICS`. The lookup went through lofty's generic tag via
`ItemKey::from_key(tag_type, "SYNCEDLYRICS")`, which returns `None` because the
key is not one lofty knows — and the generic tag drops unknown Vorbis comments
entirely on conversion, so the value was not reachable there at all. The branch
had been dead since it was written; files fell through to the `LYRICS` field or
reported no lyrics.
Read the comment block off the concrete file type (FLAC, Vorbis, Opus, Speex)
instead, keeping the documented priority: SYNCEDLYRICS before plain LYRICS.
Tests cover the reader end to end against generated files: SYNCEDLYRICS and
LYRICS returned verbatim (Enhanced LRC word stamps included), SYNCEDLYRICS
winning over a plain fallback, USLT returned verbatim, and SYLT rebuilt as
line-level LRC — which is why that source can never carry word timing.
* docs(changelog): Vorbis SYNCEDLYRICS fix (#1267)
* fix(lyrics): strip Enhanced LRC word markers from displayed text
The LRC parser only matched the leading line stamp and passed the rest of the
line through as text, so Enhanced LRC inline markers rendered literally:
`<00:12.34>Hello` instead of `Hello`. Offline and hot-cached tracks read their
embedded tags through this parser without the server ever seeing them, so a
track with embedded Enhanced LRC showed the markers in the pane.
Parse the markers instead of ignoring them: the text is now marker-free, and
where a line carries them they drive the same word-by-word highlighting the
server's songLyrics v2 cues do. A line without markers becomes one full-line
word, so word mode never drops a line.
Move the parser out of the LRCLIB client into `utils/lrc`, since embedded tags
and Netease feed it too, and move `LrcLine` next to the other lyrics types so
the parser can produce word lines without an import cycle.
* docs(changelog): Enhanced LRC marker fix (#1266)
* feat(lyrics): read word-level timing from OpenSubsonic songLyrics v2
Navidrome 0.63 advertises songLyrics v2, which adds word/syllable cues
behind a new `enhanced` flag on getLyricsBySongId. The lyrics pane already
renders word-level highlighting for the lyricsplus provider, so the server
source now feeds the same renderer.
- Request `enhanced` only where the capability catalog says the server
speaks v2 (Navidrome 0.63+), since no spec forces a v1 server to ignore
an unknown parameter.
- `enhanced=true` also returns translation and pronunciation layers, so the
response is filtered to the main layer before display.
- Map cue lines to word lines: a missing cue `end` is all-or-nothing per
line and resolves from the next cue, then the line end; multi-voice lines
share an index and keep the main agent; a line without cues stays a single
full-line word so no line is dropped.
- Bump the lyrics IndexedDB cache so line-only entries cached under the
90-day TTL cannot suppress word timing.
- Move the shared lyrics value types out of the hook, which also drops the
useLyrics/lyricsPersistentCache import cycle.
* i18n(settings): add the server word-sync hint
One line explaining what word-by-word lyrics need from the server.
* feat(settings): surface the server word-sync requirement in Lyrics Sources
Word-by-word lyrics need a recent server and lyrics that actually carry word
timing, which is not obvious from the source list alone.
Rebuild the block on the settings sub-card primitives while adding the hint:
the section description moves into the SettingsSubSection slot, the source
list and its ordering hints become a SettingsSubCard/SettingsField, and the
hint uses the field's note slot instead of a hand-rolled div borrowing an
unrelated class.
* docs(changelog): server word-level lyrics (#1265)
Adds the changelog entry, the contributor credit, and a What's New highlight
for the 1.50.0 line.
* feat(fullscreenPlayer): add Prism style — full-bleed backdrop, right lyrics panel, glass control bar
Third fullscreen player style (Settings → Appearance). Reuses the existing
pipeline: full-bleed artist backdrop (fanart/artistBackdrop), FsLyricsApple in a
floating right-side glass panel, player-store transport, cover-derived accent.
New: the bottom bar layout (transport · time elapsed/−remaining · now-playing
pill with integrated scrubbable progress · volume/queue/lyrics-toggle/minimize)
and fullscreen-player-prism.css. Wired into the style toggle + AppShell routing
+ picker + 13 locales.
* fix(fullscreenPlayer): Prism controls stay visible; transport/utils float bare (no 3-box split)
* fix(fullscreenPlayer): Prism bottom bar is one continuous glass bar (not three separate elements)
* fix(fullscreenPlayer): Prism control row is a centred middle band; only the now-playing element is boxed
* fix(fullscreenPlayer): Prism bar has both an outer glass strip and a darker nested now-playing box
* fix(fullscreenPlayer): Prism outer bar is half-width and centred
* feat(fullscreenPlayer): Prism lyrics — progressive blur on upcoming lines + accent-tinted active line
* style(fullscreenPlayer): centre the title/album/artist text in the Prism now-playing pill
* refactor(fullscreenPlayer): share seek/volume/backdrop/time across FS players
The Prism player had copy-pasted the seekbar, volume, time readout and
backdrop resolution from the Static/Immersive players. Two of those copies
carried bugs: the progress input dropped FsSeekbar's touch/pointer handlers
(no scrubbing on touchscreens) and the volume toggle only recorded the
pre-mute level on the mute click (unmuting after dragging the slider to 0
restored a stale value).
Extract the shared logic into single sources of truth and rewire all three
players onto them:
- useFsArtistBackdrop — the artist-backdrop URL resolution (was duplicated
verbatim in Static, Immersive and Prism).
- useImperativeSeek — the drag/preview/commit + progress-subscription loop
with mouse, touch, pointer and keyboard handlers; FsSeekbar and Prism's
progress line now share it, so touch scrubbing works everywhere.
- useVolumeToggle — mute toggle that continuously tracks the last non-zero
volume, restoring it correctly regardless of how the level reached 0.
- FsTimeReadout gains `remaining`/`className` props and replaces Prism's
bespoke time component.
Drops a dead aria-valuetext no-op on the progress input. Adds regression
tests for the volume-toggle restore path and a Prism smoke test.
* docs(changelog): add Prism fullscreen player style (#1251)
* feat(fullscreenPlayer): recover immersive player — components, hooks, CSS, settings
Phase 1 of the Minimal/Immersive style toggle. Recover the pre-#1001 fullscreen
player from history into the feature folder and make it compile against the
current architecture:
- FullscreenPlayerImmersive + Fs{Art,Portrait,Seekbar,LyricsMenu,LyricsRail}
- hooks useFsDynamicAccent, useFsArtistPortrait
- fullscreen-player-immersive.css (core/mesh/portrait/controls/seekbar + rail +
lyrics-menu + no-compositing overrides), shared Apple-lyrics stays in
adaptive-portrait.css
- re-add settings removed in #1001: showFullscreenLyrics, fsLyricsStyle,
showFsArtistPortrait, fsPortraitDim; plus the new fullscreenPlayerStyle toggle
Not wired into the shell yet (Phase 2). The two hooks still trip the current
set-state-in-effect lint rule — both are reworked in Phase 3 (portrait rewire to
artistBackdrop + dynamic-accent perf gating), which resolves it.
* feat(fullscreenPlayer): style toggle — route Minimal/Immersive + Appearance picker (13 locales)
* feat(fullscreenPlayer): immersive artist portrait via fanart backdrop pipeline; fix hook lint
* refactor(playback): add feature barrel; route immersive imports through barrels (dep:check)
* fix(fullscreenPlayer): restore lyrics-style i18n (13 locales) + close popover on style pick
* feat(fullscreenPlayer): immersive Apple-lyrics mode shows artist image as dimmed full-screen backdrop
* fix(fullscreenPlayer): address code-review findings on the immersive player
- star: pass currentTrack.serverId to queueSongStar (multi-server correctness)
- cover: use album-keyed ref (useAlbumCoverRef) to stop per-track cover flicker
- backdrop: honour backdrops.fullscreenPlayer.enabled (toggle was ignored)
- lyrics popover: Escape now closes only the popover, not the whole player
(capture-phase listener + stopPropagation vs useFsIdleFade's bubble handler)
- settings: recover the Show-artist-photo toggle + photo-dim slider (13 locales),
shown for the immersive style — the persisted settings had no UI
- apple mode: don't mount the (CSS-hidden) portrait — the full-screen backdrop
already shows the image; avoids a duplicate 2000px load/decode per track
- dynamic accent: cache-as-source-of-truth + last-shown ref so a cache-hit album
no longer surfaces a stale accent from an earlier album
- rehydrate: clamp fsPortraitDim (0–80) so a malformed value can't yield NaN dim
- FsArt: clear the layer when a track has no cover (was leaving prior art)
- FsSeekbar: pause the progress subscription during keyboard seeking (onKeyDown)
* fix(fullscreenPlayer): stop the immersive scrim over-darkening the artist portrait in rail mode
* fix(fullscreenPlayer): dynamic cover accent — re-run extraction when the async cover src resolves
* refactor(fullscreenPlayer): dynamic accent reads the cover blob via the cover cache, not a raw fetch
* test(fullscreenPlayer): unit-test dynamic accent hook + immersive player render/control smoke
* docs(changelog): note fullscreen player styles + credits (#1249)
* fix(fullscreenPlayer): satisfy npm run lint — drop no-explicit-any casts and ref-in-render
* fix(themes): theme card what's-new shows only the latest version
* feat(themes): credit community theme authors in Settings System tab
* docs(changelog): note theme contributor credits and card what's-new (#1248)
* fix(discord): drop server cover source that leaked credentials
The "server" Discord cover source built an authenticated Subsonic
getCoverArt URL (carrying the username, auth token, and salt) and handed
it to Discord as the large image. Discord fetches external images through
its own proxy and exposes the full source URL to anyone viewing the rich
presence, leaking replayable server credentials.
- Remove the "server" cover source; keep "None" (app icon) and
"Apple Music" (iTunes, credential-free), both resolved without server auth
- Migrate persisted "server" preference to "None" on rehydrate; new default
is "None"
- Drop the now-dead frontend cover-URL builder and its test
- Move the two-option picker to the shared SettingsSegmented control
- Remove the obsolete locale key across all 13 locales
* docs(changelog): note Discord server cover credential fix (#1246)