* refactor(virtual): extract scrollMargin measurement into a reusable hook
VirtualCardGrid carried its own useLayoutEffect + ResizeObserver block for
the scroll-margin computation introduced in #764. The same pattern is
needed on every other useVirtualizer site whose wrapper sits below other
content; pull it out as useVirtualizerScrollMargin so each call-site is a
single hook invocation instead of a 20-line duplicate.
No behavior change for VirtualCardGrid — same scroll element, same deps,
same observed nodes.
* fix(virtual): apply scrollMargin to the remaining useVirtualizer call-sites
#764 fixed the symptom for VirtualCardGrid (Albums / Artists grid via the
shared component, Composers grid, "More by …" rail, etc.) but four more
useVirtualizer call-sites have the same shape — wrapper sits below a
sticky page header and TanStack would otherwise place rows starting at
the scroll-element top, unmounting rows that are still in the viewport
and at higher offsets refusing to render at all:
- Artists grid view (Artists.tsx + ArtistsGridView.tsx) — under the
sticky title + filter row + alphabet bar (~150–250 px depending on
alphabet wrap).
- Artists list view (Artists.tsx + ArtistsListView.tsx) — same header
stack; flat letter/row stream means a noticeable jump on long
libraries.
- Composers list view (Composers.tsx) — identical header stack to
Artists.
- VirtualSongList — ~36 px sticky song-list header inside its own
scroll container; the offset was small enough to be absorbed by
overscan, but the pattern was the same so wire it up for consistency.
Each call-site now feeds wrapRef + scroll-element lookup into
useVirtualizerScrollMargin and forwards the value into useVirtualizer +
the translateY of each rendered row.
* docs(changelog): note PR #766 under Fixed in v1.46.0
* fix(audio): resume playback seamlessly on output device switch (#743)
When the OS default output device changed (Bluetooth, USB DAC, HDMI),
rodio/cpal had to reopen the stream, which silently stopped the active
sink and left the engine with no playback — causing the track to restart
from the beginning (or not restart at all after the null-payload bug).
Root cause (null-payload):
audio_set_device emitted () (unit), which Tauri serialises to JSON
null. The null-guard added for the "Rust handled replay internally"
signal was therefore also triggered by manual device switches, so
playTrack was never called and the engine stayed silent.
Fix: audio_set_device now emits the current playback position as f64
(null remains the exclusive "Rust handled" sentinel).
Rust-side seamless replay (device watcher path):
reopen_output_stream captures a ResumeSnapshot before the blocking
stream reopen, then calls try_resume_after_device_change, which:
- local files (psysonic-local://): reopens the file, builds a new
seekable source, seeks to the saved position — zero frontend
round-trip, no audible restart.
- fully-cached HTTP tracks (stream_completed_cache / spill file):
replays from the in-memory or on-disk bytes — no re-download.
- partial downloads / radio / paused: returns false → falls back to
the existing frontend path (seekFallbackVisualTarget + playTrack).
Frontend (useAudioDeviceBridge):
null payload → Rust already resumed; skip playTrack.
number payload → call playTrack + seekFallbackVisualTarget(position).
Visibility: pub(super) → pub(crate) on the play_input / progress_task
helpers that device_watcher.rs needs to call directly.
* refactor(audio): split device_resume module; add bridge tests
Split the 551-line device_watcher.rs (above the ~500-line soft ceiling)
by extracting ResumeSnapshot and try_resume_after_device_change into a
dedicated device_resume.rs module. device_watcher.rs is now 320 lines,
device_resume.rs 258 lines.
Add useAudioDeviceBridge.test.ts: 9 characterisation tests covering the
null-payload guard ("Rust replayed, skip playTrack"), the seek-fallback
path (position > 0.5 s sets seekFallbackVisualTarget), paused-device
branch (resetAudioPause), and the device-reset event path.
* chore(changelog): add entry for #765 (device switch seamless resume)
react-virtual computes virtual-item positions relative to the scroll
element, but the wrapper isn't at the scroll element's top — on Album
Detail the "More by …" grid sits under the entire tracklist. Without a
matching `scrollMargin`, the virtualizer thinks every row is far above
the viewport and unmounts it, so cards visibly disappear as you scroll
past long tracklists and never appear at all once the offset exceeds
the viewport height (52-track album in the report).
Measure the wrapper's Y-offset against the scroll element inside a
useLayoutEffect, pass it as `scrollMargin`, and translate each row by
`vRow.start - scrollMargin` so positions land back in wrapper-local
coordinates. ResizeObserver on the scroll element + its content keeps
the offset correct when content above grows. `getTotalSize()` already
subtracts the margin internally (virtual-core/src/index.ts:1304), so
the wrapper height stays unchanged.
Affects every page using VirtualCardGrid; the bug only surfaced on
Album Detail because every other call site puts the grid near the top
of the scroll content.
Add a second entry to zz5zz's contributor block in settingsCredits.ts
that covers the steady stream of Discord bug reports alongside the
existing Norwegian (Bokmål) translation credit. Also add a "Special
thanks" banner at the top of the 1.46.0 CHANGELOG section linking to
the Psysonic Discord, since several of this release's polish fixes
landed directly off the back of those reports.
Playing/shuffling a big playlist set `queue` to ~10k `Track` objects.
zustand `persist` then `JSON.stringify`'d the whole queue into `localStorage`
on every persisted `set` → `QuotaExceededError` storm: playback never started
and the main thread stalled on each write.
`partialize` now persists only a ±250-track window around `queueIndex`
(remapping the index into the slice) instead of the entire queue.
The authoritative full queue lives server-side — synced via Subsonic
`savePlayQueue` and restored through `getPlayQueue`. localStorage is only a
fast local cache, so bounding it doesn't lose the queue; it just restores a
±250 window instantly while the full queue rehydrates from the server.
Controlled before/after on a 10,509-track playlist:
- before: QuotaExceededError ×9, play/shuffle dead
- after: QuotaExceeded = 0, playback works
When the username + client string in the Live dropdown is long, the
"• Xm ago" suffix collided with the truncated username and broke
vertically into "1m / ago". Move the timestamp onto its own row under
the user line, prefixed with a clock icon so it visually aligns with
the user icon above it.
`.np-empty-state` hardcoded `rgba(255, 255, 255, 0.5)` so the "Nothing
playing yet…" message and its music-note icon were invisible against
light themes (white text on white background). Switch to
`var(--text-muted)`, matching the mobile `.mp-empty` rule and the
shared `.empty-state` utility.
Auto previously passed `undefined` to `toLocaleTimeString`, which falls
back to the JS engine's default locale. Inside WebKitGTK that resolves
to `en-US` for many users regardless of `LC_TIME`, so the queue ETA and
sleep-timer preview rendered 12h AM/PM even when the OS clock was 24h.
Pass `i18n.language` instead, matching the convention already used in
the theme-scheduler hour picker (AppearanceTab.tsx). Explicit 12h/24h
choices still override.
* fix(stream): defer M4A probe until moov tail or fast-start prefix
Ranged moov-at-end M4A started Symphonia format probe as soon as ~384 KiB
linear data arrived, before the parallel tail prefetch filled the moov
atom — probe hit end of stream and skipped the track. Wait for tail_ready
or detect fast-start moov in the prefix; do not arm playback from linear
bytes alone when tail prefetch is active.
* fix(isomp4): skip EOF-spanning mdat after moov-at-end is parsed
Second-pass header scan with moov already loaded still tried to read
through mdat→EOF on RangedHttpSource holes, causing format probe
end of stream. Re-check play generation after moov wait.
* fix(isomp4): fix AtomIterator overread after seek in patched demuxer
`AtomIterator::new_root(reader, len)` treats `len` as bytes available
from the current `reader.pos()`, not the absolute file length. After
`mss.seek(resume_at)` we were passing the absolute `total_len`, so the
iterator thought there were `total_len` bytes left and tried to read
past EOF on the next iteration, returning "end of stream".
Fix: pass `total_len.map(|tl| tl.saturating_sub(resume_at))` (remaining
bytes from the new position) in both branches:
- `moov.is_none()` (moov-at-end layout, seek to moov offset)
- `moov.is_some()` (fast-start layout, skip bounded mdat body)
This caused Symphonia to fail probing moov-at-end M4A files read from
local disk (hot-cache) and from in-memory buffers — every decode attempt
returned "end of stream", analysis fell back to `byte_envelope_no_ebu`
(no EBU R128 loudness), and rodio produced distorted audio.
Also in this commit:
- `resolve_playback_format_hint()` helper to resolve hint from URL,
stream suffix, Content-Disposition, or byte sniff
- ISO-BMFF diagnostic helpers (`isobmff_buffer_looks_complete`,
`log_isobmff_buffer_diagnostic`, `mp4_suspect_zero_holes`)
- Probe-fallback path for ranged-stream failures now uses these helpers
to decide whether to refetch or wait for the in-flight download
* chore(audio): remove redundant hint recomputation and add missing blank line
`bytes_hint_for_wait` in the ranged-stream fallback path was an exact
duplicate of `effective_hint` already in scope — reuse the existing binding.
Also add missing blank line after `wait_for_ranged_mp4_probe_ready` in mod.rs.
* chore(release): CHANGELOG and credits for PR #757
Add Fixed entry for M4A moov-at-end probe fix and credits line in
settingsCredits.ts under existing cucadmuh contributions.
* fix(audio): extract BuildSourceArgs to fix clippy::too_many_arguments
`build_playback_source_with_probe_fallback` had 12 parameters, exceeding
the clippy limit of 7. Group url/gen/hints/fade/hi-res/duration into
`BuildSourceArgs` so the function signature stays at 4 arguments.
* feat(album): show OpenSubsonic disc subtitles after the CD heading
Multi-disc albums in OpenSubsonic / Navidrome carry a per-disc
subtitle (`discTitles`) — e.g. "Sessions" on CD 3 of a deluxe
edition. AlbumTrackList only rendered "CD N" and dropped the
subtitle, so users couldn't tell two discs apart unless they read
the track names.
* `SubsonicAlbum.discTitles` typed; `getAlbum` forwards it as-is.
* `AlbumTrackList` and `AlbumTrackListMobile` take a discTitleByNum
map and render the subtitle in the disc separator after "CD N".
* Heading bumped slightly (13 → 15 px, icon 16 → 18 px) so the disc
separator stays legible next to the new subtitle.
* docs(changelog): note disc subtitles after CD heading (#753)
The speaker button in the player bar toggled volume to 0 (or back to
the pre-mute level) but exposed no `data-tooltip`, so hovering it
showed nothing — users couldn't tell what the icon did. Add a tooltip
that switches between "Mute" and "Unmute" with the current state, and
mirror the same string on `aria-label` for screen readers. New
`player.mute` / `player.unmute` keys land across all 9 locales.
The action cluster (Test Connection / Use / Delete) had `flex-shrink: 0`
and the parent flex row had no `flex-wrap`, so on narrower Settings
panes the buttons spilled past the card's right edge instead of
flowing under the server info. Enable `flex-wrap: wrap` on the parent
and pin the actions with `margin-left: auto` so they stay right-aligned
when they wrap to a new line.
* fix(library): show empty-state on Mainstage, Albums, New Releases, Random Albums
When the active library has no albums, Mainstage and the three album-list
pages rendered a fully blank page — every rail and grid was empty but
nothing told the user why. Add a shared `common.libraryEmpty` message
(all 9 locales) and render it in place of the empty grid:
* Home: when no rail has any albums after loading.
* Albums: when no albums and no filter (genre/year/starred/comp) is
active — filtered "no matches" is a separate state and stays as-is.
* New Releases: when no albums and no genre filter is active.
* Random Albums: when no albums after loading.
Pages that already had a dedicated empty-state (Artists, Genres,
Composers, Playlists, Favorites, MostPlayed, LosslessAlbums,
LabelAlbums, InternetRadio) are left alone — their wording is
intentional and per-page.
* docs(changelog): note empty-library states on Mainstage / album lists (#750)
`▶` (U+25B6, Geometric-Shapes) renders well below the baseline in most
OS title-bar fonts (Segoe UI on Windows, Cantarell on GNOME, etc.),
while `⏸` (U+23F8, Miscellaneous-Technical) sits correctly centred.
Switching the play glyph to `⏵` (U+23F5) — the designated `⏸` pair from
the same Unicode block — keeps the two states visually aligned with
the surrounding text. In-app custom title bar already looks correct
and is left untouched.
Navidrome / Subsonic `getArtistInfo2` often returns a `largeImageUrl`
that 404s when the artist has no scraped image. The hook returned the
URL as-is, the FullscreenPlayer's `artistBgUrl || coverUrl` chain saw
a non-empty string and skipped the cover-art fallback, and FsPortrait
rendered an `<img>` whose `src` failed — leaving the browser's default
broken-image glyph in the middle of the screen.
Preflight the URL via an `Image()` probe and only expose it once it
actually loads; on error leave the hook's state empty so the cover-art
fallback in the caller takes effect.
* fix(equalizer): redraw curve when surrounding <details> toggles open
ResizeObserver does not reliably fire for the `display:none → block`
transition that the SettingsSubSection `<details>` causes when the user
expands the Equalizer panel after collapsing it. drawCurve bails on a
zero-sized canvas (existing macOS-WebKit guard), and without a new
trigger the second open leaves the frequency-response canvas blank.
Add a `toggle` listener on the closest `<details>` ancestor; on open,
redraw after one rAF so the canvas has its laid-out size. Additive —
doesn't change the working ResizeObserver path.
* docs(changelog): note Equalizer canvas redraw fix (#747)
* fix(favorites): artist link in songs table no longer triggers playback
The artist cell's click handler navigated without `stopPropagation`, so
the row's onClick still fired and started the track. Matches the album
cell's behaviour and the other tracklists (SongRow, PlaylistTracklist,
PlaylistSuggestions, SongCard, AlbumCard) — all of which already guard
the navigation click.
* fix(favorites): inline bulk action chips in section header
The full-width bulk-action bar above the column header pushed every row
down by ~36 px when an item was selected, making it harder to pick
adjacent items. Move the "X selected / Add to playlist / Clear" cluster
into the existing action-buttons row (right-aligned via margin-left:
auto), matching the album toolbar pattern. Selection mode no longer
shifts the rows. Clear-selection button uses `btn-surface` to match the
other secondary actions on the page (post-#745 convention).
* docs(changelog): note Favorites artist-link + bulk-bar fixes (#746)
* fix(ui): square shape across badges, pills and non-player buttons
zunoz on Discord flagged inconsistent shapes for play buttons, badges
and pills across the app. Unify to var(--radius-sm) for non-player
surfaces so the same visual indicator looks identical wherever it
appears. Player Bar, Fullscreen Player and Mini Player keep their
circular shape as part of the player family; toggle switches, sliders,
search input, pagination dots and theme overrides are left alone.
Covers: hero play + nav arrows, album-card details button, album
header icon buttons, playlist suggestion play, album-row nav arrows;
.badge (incl. New / album-detail), genre pill, np chip/tag/badge,
np-dash toolbar badge, radio filter chip, radio card chip, mp album
plays pill, settings search-result badge, alphabet filter buttons,
download hint, mobile search chip, artist release-group count, artist
external link, all Orbit session pills, device-sync count badge;
ServersTab "Aktiv" badge, PlaylistCard loading badge, AlbumRow /
ArtistRow "more" buttons.
* fix(composers): collapse empty space between virtual rows
The composer grid uses text-only tiles (~78 px intrinsic) but
estimateRowHeightPx scaled with cell width like the image variants,
clamped to a 200 px maximum. On normal viewports every virtual row
reserved ~200 px while the actual card was ~78 px, leaving ~120 px of
empty space below each row.
Pin the composer variant to min === max so the rowHeight is a fixed
88 px regardless of cell width.
* fix(ui): unify secondary action buttons on btn-surface
Action rows on the Artist, Album, Tracks, Favorites and Most Played
pages mixed btn-ghost (borderless), btn-surface (bordered) and bare
.btn (no variant, picked up bordered look in light themes only).
Result was per-page and per-theme inconsistency — secondary buttons
sometimes had borders, sometimes not.
Unify on btn-surface for all secondary actions so the same affordance
looks identical across pages and the difference between themes is just
border tone, not border presence.
- Tracks hero: Enqueue and Reroll buttons
- AlbumHeader: Shuffle, Enqueue, Star, Share, Bio, Download and the
Offline-cache states
- MostPlayed: sort toggle and compilations filter (now matches the
Albums page header)
* fix(hero): make pagination dots visible on light backdrops
The pagination dots used `rgba(255, 255, 255, 0.35)` which disappears
against white-dominant cover art and on light themes, even under the
hero gradient overlay. zunoz on Discord reported the inactive dots as
effectively invisible.
Bump inactive dots to 85 % white with a dark outline + drop shadow so
they read against any backdrop, and switch the active dot to the
accent color so the highlight reads as colour, not just width.
Also opaque-fill `.badge` (`var(--accent)` + `--ctp-crust` text) so the
hero pills do not disappear against light cover art either — base
class previously used `--accent-dim` which is too transparent for
badges per the existing badge rule.
* fix(tracks): align Browse-all-tracks header with rows
The Tracks page header sat outside the scroll container while rows
sat inside it, so the scrollbar gutter shrank only the rows and the
last header column drifted right of its row data. With OpenDyslexic
selected the wider glyphs pushed "Duration" past the viewport edge
entirely; zunoz on Discord reported it.
Move SongListHeader inside the scroll container with position:
sticky so header and rows share the same width budget. Sticky
keeps the header visible while scrolling as a side benefit.
* test(cardGridLayout): pin composer variant to fixed row height
Guards the fix that collapsed the empty space between Composers grid
rows. Re-introducing the `cellWidthPx + extra` scaling for composer
would silently bring back ~120 px of dead space per virtual row, so
the test asserts the fixed 88 px output across a wide cellWidth range.
Image variants (artist / album / playlist) get baseline assertions so
the composer case is documented as the deliberate exception, not an
oversight.
* docs(changelog): UI consistency sweep (PR #745)
The search toggle in Settings shifted everything 1-2 px down when
opened because the open input wrap was taller than the closed icon
button, and the header row sized to its children. Lock the header to
a min-height of 40 px so both states fit, give the icon button a
proper 28x28 hit area with hover state, and pin the open input wrap
to 32 px so toggling never reflows the row.
* feat(settings): clock format setting (Auto / 24h / 12h)
Reported on the Psysonic Discord — the Queue side panel's ETA label
and the sleep-timer preview both render via `formatClockTime`, which
just calls `toLocaleTimeString` and so follows the user's system
locale. On en-US that means AM/PM, with no in-app way out.
Add a tri-state **Clock Format** setting under
**Settings → System → App Behavior**:
* `auto` (default) — keep the existing locale-driven behaviour, so
bestehende installs are unaffected on first launch.
* `24h` — force 24-hour wall-clock output everywhere
`formatClockTime` is used.
* `12h` — force AM/PM output.
Wired through `authStore` (`clockFormat`, `setClockFormat`), exposed
via `CustomSelect` in `SystemTab`, and threaded into the two
consumers (`QueueHeader`, `PlaybackDelayModal`) so they re-render on
change. `formatClockTime` itself stays a pure helper — it accepts the
setting as an optional second argument and maps it to `hour12`.
Locale coverage: all nine bundled locales (en, de, es, fr, nl, nb,
ru, zh, ro) get the four new settings strings. Pin tests added for
the `setClockFormat` setter and the `hour12` mapping in
`formatClockTime`.
* docs(changelog): clock format setting + contributors (PR #742)
* fix(home): mainstage row matches "New Releases" sidebar + page label
The Mainstage row whose title chevron links to `/new-releases` was
labelled **Recently Added** (`home.recent`) while the sidebar entry and
the page itself are **New Releases** (`sidebar.newReleases`) — three
labels for the same destination. Reported on the Psysonic Discord.
Reuse `sidebar.newReleases` in both consumers (the row title in
`Home.tsx` and the section label in `HomeCustomizer.tsx`) so the
string lives in exactly one place. The now-orphaned `home.recent` key
is dropped from all nine locale files.
* docs(changelog): mainstage New Releases label fix (PR #741)
* fix(stats-export): full-resolution preview, fit Square in modal
Reported on the Psysonic Discord — three connected issues with the
**Share Top Albums** dialog on the Statistics page:
1. **Square preview clipped.** `PreviewFrame` capped only `maxHeight: 52vh`
while letting `width: 100%` + `aspectRatio: 1/1` push the 1:1 canvas
far past the cap; `overflow: hidden` then chopped the bottom rows
with no way to scroll them in (Twitter is width-bound, Story is
width-capped at 320px, so Square was the only ratio that overflowed).
Cap **both** dimensions per format — Square gets `maxWidth: 52vh`,
Story gets `min(320px, calc(52vh * 9/16))`, Twitter stays
width-bound by the modal — so the preview always fits.
2. **Outer scrolling didn't reveal the bottom.** Same root cause: the
modal-content scroll only exposed the action buttons; the canvas
itself sat inside `overflow: hidden` with nothing more to scroll to.
Fixed implicitly by (1).
3. **Preview is blurry.** `PREVIEW_MAX_WIDTH = 540` rendered a 540×540
canvas that CSS then stretched back to ~676px in a 720px-wide modal,
and `desiredTilePx = 256` decoded covers at 256 px only to upscale
them into ~300 px tiles. Render the preview canvas at the full
export width (1080) and decode covers at the export tile size (600)
so text is sharp and covers downsample crisply.
* docs(changelog): stats-export preview fix (PR #740)
* fix(artist-info): id-gate fetcher tuples, reuse ArtistCard on ArtistDetail
The cache-mismatch bug PR #732 fixed in `NowPlayingInfo.tsx` had the same
shape inside `ArtistCard` on the NowPlaying page: `useNowPlayingFetchers`
returned `artistInfo` for the previously-current artist for one render
after `artistId` changed, and `CachedImage` persisted that mismatched
blob under the new `artistInfo:<new-id>:hero` key in IndexedDB —
sticky "previous artist" image on every subsequent track.
Apply the same `{ id, value }` tuple pattern from PR #732 inside the
hooks themselves so every consumer is safe by construction:
- `useNowPlayingFetchers`: gate `artistInfo`, `songMeta`, `albumData`,
`discography` on id-match at the return. Late-arriving resolves for
a stale id can no longer overwrite the displayed value.
- `useArtistDetailData`: same for `info`. Required because the
`ArtistDetail` bio card now uses `CachedImage` via the shared
`ArtistCard` (previously raw `<img>`, no persistence hazard).
Unify `ArtistDetail`'s inline "About the Artist" block onto the same
shared `ArtistCard` so there is one source of truth for hero / bio /
similar rendering. New optional props: `onNavigate?` (omitted on
`/artist/:id` since the user is already there), `coverFallback`
(coverArt fallback when artistInfo has no hero image),
`hideArtistName` (avoid duplicating the hero name), `hideSimilar`
(ArtistDetail has its own similar-artists section).
Tests cover the gating contract for both hooks (incl. stale-resolve
race) and the `ArtistCard` prop matrix.
* fix(artist-image): square queue info hero, drop artist-avatar glow
- Queue Info bar's artist hero was rendered in a 16:10 wrap with
`object-fit: cover`, so portrait photos lost top/bottom equally
while landscape ones lost the sides — perceived as cropped even
on roughly square sources. Set the wrap to 1:1 so the crop is
symmetric and matches the typical square framing of artist
photography.
- `ArtistDetail` extracted the cover's accent colour on every image
load and rendered a 36px / 8px-spread `boxShadow` ring around the
avatar. Drop the glow, the state, the one-shot reset effect, the
prop-passing through `ArtistDetailHero`, and the now-orphaned
`extractCoverColors` import on this page. `extractCoverColors`
itself stays in place (still used by `useFsDynamicAccent`).
* docs(changelog): artist-info image fix extension + UI tweaks (PR #739)
Without async-io (or tokio), zbus 5.15 with default-features = false
fails to compile (`Either "async-io" (default) or "tokio" must be enabled`),
which in turn broke `psysonic-audio` and the root `psysonic` crate on
clean rebuilds. Workspace `cargo --workspace` builds happened to succeed
because feature unification masked the gap; standalone clean builds did not.
* feat(playback): stream buffering UI, ranged M4A tail prefetch, demuxer fix
Defer seekbar/progress until HTTP stream is armed for both legacy and
RangedHttpSource; show buffering overlay on cover art. Add MP4 tail
prefetch and Symphonia isomp4 bounded-mdat/moov-at-EOF probing so
moov-at-end M4A can start without reading the full mdat.
* feat(hot-cache): spill large ranged streams to disk for promote
When a ranged HTTP download completes above the 64 MiB RAM promote cap,
write the existing buffer once to app-data stream-spill/ and register it
for hot-cache promote (rename) and replay via fetch_data. Analysis seeds
from the spill file up to the local-file cap (512 MiB).
* fix(ui): stream buffering — grayscale cover and static clock icon
Desaturate player and queue cover art while isPlaybackBuffering; keep a
non-animated clock overlay for visibility without the spinning animation.
* fix(playback): review follow-up — tests, i18n, spill cleanup, changelog
Clippy and test layout fixes; stream spill orphan cleanup on startup;
buffering flag guard in progress handler; bufferingStream in all player
locales; CHANGELOG and contributor credits for stream/M4A work.
* docs: attribute stream buffering and M4A streaming to PR #737
* test(audio): avoid create_engine in stream spill unit test
CI runners have no audio output device; test spill take/consume via
the Mutex slot only, matching install_stream_completed_spill tests.
Clicking a card under Top Artists by Favorites set the artist filter
to the artist's Subsonic ID, and the "Showing X of Y" label
interpolated that ID into the `{{artist}}` placeholder — so the user
saw a GUID like "OjdsOiMQ6ve5rZWPj2ePFc" instead of "Toto".
Look the name up from `topFavoriteArtists` (already on the page,
each entry carries `{id, name}`), and pass it to the header. The ID
filter itself is unchanged — the song-filtering hook still matches on
`artistId` / `artist` / `albumArtist`.
Reported by zunoz on Discord.
* feat(hero): prev / next arrows on the Mainstage featured strip
Adds left and right chevron buttons over the featured-album hero so a
single click flips to the previous / next album. Hitting the 8 px dot
indicators was awkward and often opened the underlying album by
mistake; the arrows give a generous 44 px touch target on each edge.
- New `goPrev` / `goNext` callbacks wrap with modulo, restart the
auto-advance timer on click (same pattern the old dot handler used).
- Buttons live in a new `.hero-nav` flex wrapper with `inset: 0` and
`justify-content: space-between`, so they pin to the hero's left and
right edges regardless of theme padding. `pointer-events: none` on
the wrapper + `auto` on the buttons keeps the rest of the hero
click-through (navigate to album).
- Dots are now decorative spans, not buttons — `pointer-events: none`,
no hover state, no `onClick`. Clicking near a dot used to navigate
to the album because the dot was too small to land on.
- i18n: `previousAlbum` / `nextAlbum` keys in all 9 locales.
Reported by zunoz on Discord.
* docs(changelog): note Mainstage hero prev / next arrows (#735)
* fix(album): contain Artist Biography modal scroll within frame
The modal was rendered inside the album page tree, where an ancestor
broke `position: fixed` so the overlay scrolled with the page instead
of pinning to the viewport. Long bios also pushed the modal past the
viewport entirely.
- Render `BioModal` via `createPortal(document.body)` so no ancestor's
transform/filter/backdrop-filter can break fixed positioning.
- New `.modal-content.bio-modal` variant: flex column, `overflow:
hidden`. Title + close button stay pinned, only `.bio-modal-body`
scrolls. The existing `max-height: 80vh` on `.modal-content` now
reliably caps the modal to the visible viewport.
Reported by zunoz on Discord.
* docs(changelog): note Artist Biography modal scroll fix (#734)
* fix(album): hide Artist Bio button on Various-Artists compilations
The Album header showed an Artist Bio button on every album, but
when the album artist label is "Various Artists" / "Various" / "VA"
or a language equivalent there is no single artist to fetch a bio
for — the button opened an empty modal. Hide both the mobile icon
and the desktop button when the label matches that heuristic.
* docs(changelog): album bio button hidden for compilations (PR #733)
* fix(queue-info): pin artist image cache key to matching info, not lagging state
`artistInfo` state and `artistId` updated on different cycles, so on
track change the info panel rendered one frame with the previous
track's `largeImageUrl` under the new `heroCacheKey`. CachedImage's
IndexedDB persisted that mismatched blob under the new key, leaving
every subsequent track stuck on the previous artist's image.
Hold artist info + song detail as `{ id, info }` tuples and gate
render on id-match so `src` and `cacheKey` always come from the
same source.
* docs(changelog): queue info artist image fix (PR #732)
* feat(tracklist): play count, last played, and BPM columns (#516)
Adds three opt-in columns to Album / Playlist / Favorites tracklists,
plus the same fields in the Song Info modal. Picks up Navidrome's
existing `playCount` / `played` / `bpm` from the Subsonic response — no
new API calls.
- `SubsonicSong` gains `playCount`, `played`, `bpm` (already populated
by Navidrome's Subsonic API, just unmapped in the TS model).
- `albumTrackListHelpers.COLUMNS`, `PL_COLUMNS` (PlaylistDetail), and
`FAV_COLUMNS` (Favorites) get the three new entries. Genre also added
to the playlist column set for parity with the other two lists.
- Render uses `.track-duration` (12px tabular, centered, muted) for the
numeric stats and `.track-genre` (11px text, muted) for the relative
last-played timestamp via the existing `formatLastSeen` helper.
- Sort logic extended in `playlistDisplayedSongs`,
`useAlbumDetailSort`, and `useFavoritesSongFiltering` for the three
new keys.
- `SongInfoModal` gets matching rows (BPM / Play count / Last played).
- `useTracklistColumns.gridTemplate` / `gridMinWidth` fall back to the
ColDef's `defaultWidth` when a visible column has no saved width
(newly added column on an old prefs blob would otherwise emit
`undefinedpx` and collapse the row layout until reset-to-defaults).
- BPM cells skip the rendering when Navidrome returns 0 (default for
untagged files) — show `—` instead of `0`.
- i18n: `albumDetail.trackPlayCount / trackLastPlayed / trackBpm` and
`songInfo.playCount / lastPlayed / bpm` in all 9 locales.
Suggested by jbigginswyl (#516).
* docs(changelog): tracklist Plays / Last played / BPM columns (#730)
Many prune/play/enqueue steps exhausted QUEUE_UNDO_MAX and dropped the
pre-mix snapshot. Push undo once before the macro rebuild; add skipQueueUndo
for enqueue and pruneUpcomingToCurrent; Lucky Mix playTrack uses manual=false.
CHANGELOG: document fix under 1.46.0 Fixed (PR #728).
* fix(ui): consolidate Orbit / Server / Live header dropdowns
The three header dropdown popovers each had their own container style.
Live used a glass utility class with backdrop-filter that read poorly on
many themes (reported by cucadmuh). Move all three onto the shared
`.nav-library-dropdown-panel` container — same bg, border, shadow and
radius via existing semantic tokens (`--bg-card`, `--border-dropdown`,
`--shadow-dropdown`, `--radius-md`). Item styles per dropdown stay
case-specific.
- NowPlayingDropdown: replace `glass animate-fade-in` with
`nav-library-dropdown-panel animate-fade-in`; drop now-redundant inline
borderRadius / boxShadow / zIndex; keep padding + gap for the
user-list breathing room.
- OrbitStartTrigger: add `nav-library-dropdown-panel` alongside
`orbit-launch-pop`. The component-local class is now reduced to the
menu-specific min-width + tighter item gap; bg / border / shadow /
radius / padding inherit from the shared container.
- Drop the unused `.glass` utility class; no other call sites.
* docs(changelog): header dropdown consistency fix (#725)
* feat(queue): persist header duration mode (#625)
The queue header chip cycles total / remaining / ETA, but the choice
lived in QueuePanel `useState` and reset to 'total' on every app launch.
Move it into `authStore` (persisted under `psysonic-auth`) so the chosen
mode survives restarts like other UI preferences.
- `DurationMode` consolidated in `authStoreTypes` (re-exported from
`queuePanelHelpers` so existing component imports stay valid).
- New `queueDurationDisplayMode` field + `setQueueDurationDisplayMode`
setter wired through `createUiAppearanceActions`, default `'total'`.
- `computeAuthStoreRehydration` validates the persisted value against
the three known modes (pattern matches the existing seekbarStyle
block) — garbage, null, undefined, or a missing key map back to
`'total'` so the chip never receives an unknown mode.
- `QueuePanel` reads / writes via `useAuthStore` selectors instead of
local `useState`.
- Tests: trivial-setter coverage in `authStore.settings.test.ts` and a
new `authStoreRehydrate.test.ts` covering corrupt, missing, and
valid rehydration cases.
Reuses kveld9's design from PR #625; not merged because the 1.46
refactor split locales and reshuffled queue-helper exports. Credited
via Co-Authored-By trailer.
Co-Authored-By: Kveld. <kveld912@proton.me>
* docs(changelog): queue header duration mode persistence (#724)
Adds the CHANGELOG entry + kveld9 credits line referencing the real PR
numbers (no #TBD placeholder) — feedback.md §2.7+§2.8 workflow: commit
docs as a second push onto the open PR.
---------
Co-authored-by: Kveld. <kveld912@proton.me>
Replace ineffective dynamic import of subsonicRatings from setRating (mix
paths already statically load it). Set Vite chunkSizeWarningLimit to 1000 kB
for desktop bundles.
Adds a new sub-section under Settings → Personalisation (Advanced) that
hides individual controls in the player bar: Star rating, Favorite
(heart), Last.fm love, Equalizer, Mini player. Last.fm love still only
renders when a Last.fm session exists; the overflow row in the player
collapses when both Equalizer and Mini player are hidden.
- New `playerBarLayoutStore` (Zustand + persist, items[{id, visible}] +
rehydrate sanitize) following the queueToolbar / playlistLayout
pattern; defaults to all visible.
- New `PlayerBarLayoutCustomizer` reuses the same row + toggle pattern
as the other personalisation customisers.
- Gates threaded through `PlayerTrackInfo` (3 controls), `PlayerBar`
(EQ + Mini buttons), and `PlayerOverflowMenu` (EQ + Mini in the
overflow row, with row-level conditional).
- `PersonalisationTab`: added as the last advanced sub-section so it
only appears when the global Advanced Mode toggle is on.
- Settings search index gets entries for both Playlist page layout and
Player bar (playlist row was missing).
- New i18n keys `settings.playerBar*` in all 9 locales.
Reuses kveld9's design from PR #627; not merged because the locale
split and the Advanced Mode refactor landed afterwards. Credited via
Co-Authored-By trailer + a new line in settingsCredits.ts under the
existing kveld9 entry.
Co-authored-by: Kveld. <kveld912@proton.me>
* feat(offline): show cached albums from all servers in Offline Library
List every offline album regardless of active server; load cover art per
source server and switch before play/enqueue. Sidebar, mobile nav, and
disconnect auto-nav use any cached content; multi-server cards show a label.
* docs(changelog): link offline library PR #719
* chore(pr-719): address review — CHANGELOG in Added, helper tests
Move release note to ## Added per team changelog policy; cover
offlineAlbumCoverArt and ensureServerForOfflineAlbum in unit tests.
Adds a per-element visibility toggle for the playlist detail page (Add
Songs, Import CSV, Download ZIP, Cache Offline, Suggestions) and reworks
the way uncommon options are surfaced: instead of a per-tab collapsible
group, a global "Advanced" toggle in the Settings header reveals all
`advanced` sub-sections across every tab and marks each one with a small
badge. Sets the pattern up so any future advanced option lives in its
natural tab, gated by the same switch.
- New `advancedSettingsEnabled` boolean on `authStore`
(UiAppearance slice, persisted with the rest of the store).
- `SettingsSubSection` gains an `advanced?: boolean` prop. Hidden when
the toggle is off; renders an "Advanced" pill in the header when on.
- Settings header gets a Toggle-Switch next to the search lupe.
- `PersonalisationTab` flattens — Sidebar + Home stay always visible;
Artist sections, Queue Toolbar, and the new Playlist layout get
`advanced` and disappear by default. `PersonalisationAdvancedGroup`
component + CSS removed.
- New `playlistLayoutStore` (Zustand + persist, items[{id,visible}] +
rehydrate sanitize) following the queueToolbarStore pattern.
- `PlaylistHero` and `PlaylistSuggestions` gate the four toolbar buttons
and the suggestions rail on the store directly.
- One-time migration in MainApp on mount: if the user had opened the
old per-tab Advanced group (`psysonic_personalisation_advanced_open
=== 'true'`) OR already customised any of the three sub-sections,
Advanced Mode auto-enables on first launch. Idempotent via a
localStorage flag; legacy key removed afterwards.
- New i18n keys `settings.advancedMode`, `settings.advancedModeTooltip`,
`settings.advancedBadge`, `settings.playlistLayout*` in all 9 locales.
Reuses kveld9's design from PR #556; not merged because the locale split
landed afterwards. Credited under the existing kveld9 entry in
settingsCredits.ts.
Co-authored-by: Kveld. <kveld912@proton.me>
* fix(ui): handle Text node targets in global selectstart blocker
selectstart can target a Text node without closest(); resolve to the
parent Element before checking inputs and data-selectable regions.
* docs(changelog): note PR #718 selectstart Text node fix
* fix(ui): narrow selectstart target to Node before parentNode
Satisfies tsc: EventTarget has no parentElement/parentNode until
narrowed with instanceof Node.
* fix(playback): pin queue streams, cover art, and library links to queue server
When the active server changes while a queue from another server is playing,
keep streams and UI on queueServerId; switch back for artist/album links and
queue or player-bar context menus.
* fix(playback): switch to queue server when opening Now Playing
Ensure active server matches queueServerId before Subsonic fetches on the
Now Playing page, mobile player route, and queue info panel; scope caches
by server id.
* docs(credits): mention Now Playing in PR #717 contribution line
* fix(playback): route scrobble and queue sync to queue server
Address PR review: apiForServer for scrobble/now-playing/savePlayQueue,
clear queueServerId on server removal, mini-player queueServerId sync,
block cross-server enqueue with toast, and regression tests.
Global queue paste opens the preview modal before play; overlay scrollbar,
context-menu suppression, changelog/credits for PR #716 and DanielWTE (#551).
Implement share-link detection in search (track, queue, album, artist,
composer): enqueue tracks/queues without interrupting playback; preview
album/artist/composer without switching the active server; queue preview
modal with scrollable track list. Based on community PR #551.
Co-authored-by: Daniel Wagner <daniel.iuser@icloud.com>
* fix(mix): apply rating filter across mixes and fix Lucky Mix queue fill
Invalidate entity rating cache on setRating and stop negative-caching
unrated artists/albums so filters see fresh stars. Honor UI rating
overrides, wire Instant Mix and CLI paths, fix Random Mix filter order,
and align Lucky Mix progress with the real player queue length.
* docs(changelog): document mix rating filter and Lucky Mix queue fix
* fix(ui): place playlist context submenus flush to trigger row
Use left/right/top 100% instead of calc(100% + 4px) so there is no dead
gap when moving the pointer from Add to playlist into the submenu.
* fix(ui): defer closing playlist submenu on trigger mouseleave
Use a short timer and :hover on the trigger row so slow moves across
border/subpixel gaps still reach the nested submenu; cancel timer on
re-enter and when the context menu closes.