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
* 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)
* 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)
* 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)
* 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)
* 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.
* 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>
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(format): round human duration totals to the nearest minute
The Phase L dedup refactor folded the old `formatAlbumDuration` helper
into `formatHumanHoursMinutes`, but the shared version truncated seconds
to whole minutes instead of rounding. Aggregate duration labels (album,
playlist, total playtime) could read up to ~59 s short and flip the
hour boundary the wrong way — a 59:30 total showed "59 m" instead of
"1 h 0 m".
Restore the round-to-nearest-minute behaviour (and the negative-input
clamp) the helper had before the consolidation. Adds a test pinning the
rounding and the hour-boundary roll-up so it can't regress again.
* docs(changelog): add human-duration rounding fix under Fixed (#710)
* fix(artists): attach infinite-scroll observer when sentinel mounts
The Artists page only renders the bottom sentinel after getArtists finishes.
The hook subscribed in an effect keyed on loadMore; unlike Albums, that
callback does not depend on loading, so the observer never attached after
the first paint. Use a callback ref and observe against the main scroll
viewport (#app-main-scroll-viewport).
* docs: changelog for Artists infinite scroll fix (PR #709)
* fix(audio): end track on sample-accurate exhaustion, not the floored duration hint
With gapless and crossfade both disabled, the end of every track was cut
short by up to ~1 s. The progress task had two competing end-of-track
signals and the wrong one won:
- the duration-hint timer fired audio:ended at exactly the Subsonic
duration, which is floored to whole seconds while the decoded audio
almost always runs slightly longer; and
- the sample-accurate NotifyingSource `done` flag, which gapless already
relies on, was only consulted when a chained successor existed.
Now the exhaustion branch emits audio:ended directly when the source is
done and no chain is queued — the real, sample-accurate track end. The
duration-hint timer is kept only as the crossfade trigger (it must fire
early, before the source exhausts) and as a watchdog for sources that
never signal exhaustion.
Adds three progress_task tests covering immediate end on exhaustion,
no premature end without crossfade, and the preserved crossfade trigger.
* docs(changelog): add end-of-track clipping fix under Fixed (#708)
* feat(http): enable gzip + brotli decompression for reqwest clients
All Rust-side HTTP clients now advertise Accept-Encoding and transparently
decode compressed responses. reqwest auto-decompresses by default once the
features are enabled, so this is a pure dependency-feature change with no
call-site edits.
Added to all five reqwest declarations across the Cargo workspace
(top psysonic crate + psysonic-audio / -analysis / -integration / -syncfs).
The real wire savings land on JSON payloads — Navidrome native /api,
Bandsintown, Radio-Browser, Last.fm — measured at roughly -76% to -93% on
earlier curl tests. Crates that only fetch already-compressed audio bytes
get the features too for consistency: reqwest just advertises the header
there, so there's no runtime cost when the server returns data as-is.
Cargo.lock grows additively (async-compression + compression codecs); no
other crates moved.
* docs(changelog): add entry for HTTP gzip + brotli (#704)
* fix(ui): use stable list keys on Now Playing dashboard cards
Subsonic payloads can repeat the same id in similar artists, album
track rows, and top songs. Keys now combine id with list index so
React reconciliation stays stable and duplicate-key warnings stop.
* docs: changelog and credits for Now Playing list keys (PR #703)
Document the dashboard list key fix in CHANGELOG and Settings contributors.
* fix(settings): sort the contributors list chronologically
The Settings → System contributors list rendered the array in raw
insertion order, so Psychotoxical (since v1.0.0) showed up last and
hand-maintained ordering drifted over time.
Sort on export instead: ascending by the `since` app version (reusing
isNewer), tie-broken by the first-contribution PR number. The list
stays correctly ordered regardless of where new entries are inserted.
* docs(changelog): add entry for contributors list sort fix (#700)
* fix(ui): split OpenSubsonic album and track artists in header and player
Album detail header uses albumArtists from album or child songs; player bar,
mobile player, and mini player use structured track artists with per-id links.
Adds deriveAlbumHeaderArtistRefs helper and OpenArtistRefInline.
Fixes#552
* docs: changelog and credits for OpenSubsonic artist links (PR #696)
* fix(player): align cached cover URL with cacheKey on track change
Prevents a one-frame stale blob src (and broken image in the player bar)
when switching tracks; reset CachedImage load state in useLayoutEffect.
* docs: changelog + credits for cover-art track-switch fix (PR #695)
* fix(sidebar): keep offline-download toast from squishing in a short window
The toast lives in the sidebar nav flex column; without flex-shrink: 0 the
column compressed it vertically when the main window was small. The label
now also ellipsis-truncates instead of overflowing on a narrow sidebar.
* fix(offline): make offline downloads cancellable down to the Rust transfer
A running offline download could not be stopped — the sidebar X button only
dropped not-yet-started tracks between batches of 8, and the Rust transfer had
no cancellation path at all, so in-flight HTTP streams always ran to completion.
Add an offline_cancel_flags() registry (mirroring sync_cancel_flags for the
device-sync side) plus additive cancel_offline_downloads / clear_offline_cancel
commands. download_track_offline takes an optional download_id, checks the flag
right after acquiring its semaphore slot, and threads it through
finalize_streamed_download / stream_to_file so an in-flight stream aborts at the
next chunk — the partial .part file is cleaned up by the existing error path.
* fix(offline): cancel per-track and clear the sidebar toast immediately
downloadAlbum tags each run with a downloadId, checks for cancellation before
every track instead of once per 8-track batch (which never re-ran for albums of
8 or fewer tracks), and persists tracks that finished before the cancel so they
are not orphaned on disk. cancelDownload / cancelAllDownloads drop every job for
the album and call cancel_offline_downloads so Rust aborts the in-flight
transfers — the toast disappears at once instead of lingering on stuck rows.
Adds offlineJobStore cancellation tests.
* docs(changelog): offline download cancel button + toast sizing fixes
* 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)
* 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>
Foundational Rust workspace split landed via #532. Crediting both
cucadmuh (M0–M7 structural work + analysis fixes) and Frank
(orbit batch + macOS gates + bonus PRs that rode along on the
branch).
* 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
* 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
* 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.