fix(player): stable playbar clocks when showing remaining time (#987)

* chore: restore PR order in CHANGELOG [1.47.0] Fixed section

Re-sort ### blocks ascending by PR number per release-note placement rules after out-of-order Discord-fix batch inserts.

* fix(player): stable playbar clocks when showing remaining time

Pad seekbar time strings to a fixed width so ticking remaining time does not resize WaveformSeek via ResizeObserver; tighten clock-to-waveform spacing and keep the toggle icon inline.

* docs: CHANGELOG and credits for playbar remaining-time fix (PR #987)

* docs: CHANGELOG entry for playbar remaining-time waveform fix (#987)

* chore: drop settingsCredits entry for minor playbar fix (#987)

* docs: move #987 CHANGELOG entry to end of [1.47.0] Fixed section
This commit is contained in:
cucadmuh
2026-06-04 17:53:36 +03:00
committed by GitHub
parent 6da98e476b
commit ca502ad833
7 changed files with 354 additions and 143 deletions
+185 -116
View File
@@ -422,116 +422,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Fixed
### Player — prefs survive restart when queue persist hits quota
**By [@cucadmuh](https://github.com/cucadmuh), reported by norp on the Psysonic Discord, PR [#958](https://github.com/Psychotoxical/psysonic/pull/958)**
* Volume, repeat mode, queue panel visibility, and the Last.fm loved-track cache no longer depend on the quota-bound `psysonic-player` blob (full `queueItems` since thin-state #872). Each pref now has its own small localStorage key with legacy migration from the old blob.
* Startup no longer overwrites saved prefs before Zustand rehydration finishes; persisted volume is pushed to the Rust engine on boot.
### All Albums — genre filter respects sidebar library scope
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#959](https://github.com/Psychotoxical/psysonic/pull/959)**
* With multiple music libraries, narrowing the sidebar to one library no longer leaves the Genre filter showing server-wide genres — options now come from the scoped local index catalog (same scope as the album grid).
### Now Playing — multi-artist links and About the Artist tabs
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#960](https://github.com/Psychotoxical/psysonic/pull/960)**
* Tracks with OpenSubsonic `artists[]` (e.g. Navidrome `feat.` splits) now expose per-artist links on the Now Playing hero and in the queue current-track row — same interaction as player bar and album track lists.
* About the Artist loads bio for each performer; when multiple artist ids are present, tabs switch between their bios, images, and similar artists instead of showing one joined name with a single profile.
### Composers — page search keeps role-split credits
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#961](https://github.com/Psychotoxical/psysonic/pull/961)**
* Scoped search on Composers no longer replaces the Navidrome role-scoped catalog with generic artist index/search3 hits that merge split composer credits into one joined name and id — results stay split like the scroll overview.
### Browse grids — multi-select ring no longer clips (WebKitGTK)
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#962](https://github.com/Psychotoxical/psysonic/pull/962)**
* Multi-select rings on Artists, All Albums, Playlists, and related card grids use an inset `::after` overlay (same approach as card focus rings) instead of `outline` on `overflow: hidden` tiles — fixes top-row clipping and the ~1px gap vs the inner border on Wayland/WebKitGTK.
### Composers — hide performer-only artists from role catalog
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#963](https://github.com/Psychotoxical/psysonic/pull/963)**
* Navidrome's composer role list can include artists with zero composer album credits (e.g. Apollo 440 with performer albums only). Composers browse/search now drops rows where `stats.composer.albumCount` is zero so ghost composer cards no longer appear.
### Mainstage — Because you listened respects sidebar library
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#964](https://github.com/Psychotoxical/psysonic/pull/964)**
* The recommendation rail picks albums from Last.fm similar artists via `getArtist`, which can ignore `musicFolderId` — picks are now filtered to the scoped library album set, and the rail cache invalidates when the sidebar library filter changes.
### Build a Mix — keyword blocks and scoped genre list
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#965](https://github.com/Psychotoxical/psysonic/pull/965)**
* Random Mix keyword filter (click-to-block artist/genre) now applies even when "Exclude audiobooks" is off — blocking the only track in a library shows an empty state after Remix instead of the excluded song.
* Genre Mix loads genres through the scoped catalog (`fetchGenreCatalog` / local index) instead of server-wide `getGenres`, matching the sidebar library filter.
### Artist detail — external link buttons keep border on hover
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#966](https://github.com/Psychotoxical/psysonic/pull/966)**
* Last.fm, Wikipedia, and Favorite used a hover border color that matched the card background — the rim disappeared instead of highlighting the inner fill like Play/Shuffle/Radio (`btn-surface`).
* Playlist detail — Play and Add Songs now show tooltips like the other header actions; track count uses proper pluralization (`1 song` vs `N songs`) with standard `count` interpolation.
* Suggested Songs rows now render BPM (and other optional columns like genre, play count, last played) — the column switch was missing those cases.
### Player transport — custom delay input validation
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#967](https://github.com/Psychotoxical/psysonic/pull/967)**
* Absurd custom minute values (e.g. eleven nines) no longer arm an immediate timer while the preview still shows a far-future start time — input is capped to the browser delay limit and Apply stays disabled when out of range.
* Fractional custom minutes (e.g. `0.1`, `0.01`) now share the same delay math between the modal preview, armed timer, and play-button countdown so the displayed remaining time matches when playback starts or pauses.
### Settings — in-page search coverage and junk-query filtering
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#968](https://github.com/Psychotoxical/psysonic/pull/968)**
* AudioMuse-AI (Navidrome) is indexed and selectable from settings search; choosing it opens Servers and scrolls to the plugin toggle when shown.
* In-app and global shortcut labels (e.g. Volume up / Volume down) appear as search hits and focus the parent shortcuts subsection.
* Nonsense queries no longer return unrelated fuzzy matches (e.g. long repeated letters).
### Floating player bar — remove full-width background strip
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#969](https://github.com/Psychotoxical/psysonic/pull/969)**
* Floating mode no longer stretches the player bar between sidebar and queue with fixed `left`/`right` — only the centered pill is painted over the page instead of a full-width black band behind the rounded corners.
### Smart Playlist editor — themed sort, stable toggles, exclude-all genres
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#970](https://github.com/Psychotoxical/psysonic/pull/970)**
* Sort dropdown uses the themed `CustomSelect` instead of a native `<select>` whose option list followed system styling.
* Include/Exclude genre and year-range mode buttons no longer jump ~1px when selected — matched button box model and disabled hover translate on mode toggles.
* Selected genres are color-coded (primary for include, danger for exclude) so they are distinguishable from available chips.
* Excluding all genres collapses to a single untagged-genre rule instead of hundreds of `notContains` filters that stalled Navidrome; empty smart playlists settle without a false "Playlist not found" after a long spinner.
### Local index live search — no junk hits on `=` and syntax characters
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#983](https://github.com/Psychotoxical/psysonic/pull/983)**
* Queries such as `1=2` or `M=c` no longer return unrelated albums and artists — FTS5 was parsing `=` and similar characters as query syntax instead of a literal token.
* Wildcard-only queries (`**`, `****`) are rejected for both local index and server search; titles that contain censorship stars (e.g. `***Flawless`) remain searchable.
### In-page browse — virtual scroll and cover-art priority
**By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)**
@@ -541,12 +431,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Lucky Mix after server switch
**By [@cucadmuh](https://github.com/cucadmuh), PR [#785](https://github.com/Psychotoxical/psysonic/pull/785)**
* Starting a mix on the browsed server no longer spams cross-server enqueue errors — unpinned or foreign queues hand off cleanly before batch enqueue.
### Radio — paused streams stay paused
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by drelabre on GitHub, PR [#786](https://github.com/Psychotoxical/psysonic/pull/786)**
@@ -555,6 +447,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Mainstage — album rail hover controls
**By [@cucadmuh](https://github.com/cucadmuh), PR [#787](https://github.com/Psychotoxical/psysonic/pull/787)**
@@ -563,12 +456,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Album grids and song rails:** overlay `pointer-events` so the dim layer does not steal hover from the card.
### Mainstage album rails — stable New badge on first hover
**By [@cucadmuh](https://github.com/cucadmuh), PR [#986](https://github.com/Psychotoxical/psysonic/pull/986)**
* Horizontal album rails (Home New Releases, Discover, Favorites, Statistics, search rows, …) no longer hide the **New** / offline cover badges during hover zoom — cover stacking is shared with grid pages; rails keep dim-on-`::before` for play controls.
### Album view — bulk add to playlist selection
@@ -578,6 +465,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Playlists — column sorting keeps the viewport in place
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#848](https://github.com/Psychotoxical/psysonic/pull/848)**
@@ -586,6 +474,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Settings — local library index exclude/include feedback
**By [@cucadmuh](https://github.com/cucadmuh), PR [#850](https://github.com/Psychotoxical/psysonic/pull/850)**
@@ -594,6 +483,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Statistics — player stats tab without local index
**By [@cucadmuh](https://github.com/cucadmuh), PR [#851](https://github.com/Psychotoxical/psysonic/pull/851)**
@@ -602,6 +492,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Playlists & Favorites — column picker on short lists
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#853](https://github.com/Psychotoxical/psysonic/pull/853)**
@@ -610,6 +501,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Browse all tracks — sticky header no longer overlapped
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#854](https://github.com/Psychotoxical/psysonic/pull/854)**
@@ -618,6 +510,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Local library index — full resync removes server-deleted tracks
**By [@cucadmuh](https://github.com/cucadmuh), PR [#861](https://github.com/Psychotoxical/psysonic/pull/861)**
@@ -626,6 +519,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Server index-key migration — unknown/legacy data handling
**By [@cucadmuh](https://github.com/cucadmuh), PR [#864](https://github.com/Psychotoxical/psysonic/pull/864)**
@@ -636,6 +530,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Now Playing — stray zero metadata badges
**By [@cucadmuh](https://github.com/cucadmuh), PR [#865](https://github.com/Psychotoxical/psysonic/pull/865)**
@@ -644,6 +539,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Analysis — failed tracks no longer block completion forever
**By [@cucadmuh](https://github.com/cucadmuh), PR [#867](https://github.com/Psychotoxical/psysonic/pull/867)**
@@ -654,6 +550,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Live Search — multi-server local index hits
**By [@cucadmuh](https://github.com/cucadmuh), PR [#868](https://github.com/Psychotoxical/psysonic/pull/868)**
@@ -663,6 +560,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Queue — mixed-server routing and quota-safe persist
**By [@Psychotoxical](https://github.com/Psychotoxical) + [@cucadmuh](https://github.com/cucadmuh), PR [#872](https://github.com/Psychotoxical/psysonic/pull/872)**
@@ -672,6 +570,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Analytics — aggressive scan no longer eats memory on big libraries
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#873](https://github.com/Psychotoxical/psysonic/pull/873)**
@@ -681,6 +580,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Home — Discover Songs cover art with local index
**By [@Psychotoxical](https://github.com/Psychotoxical) + [@cucadmuh](https://github.com/cucadmuh), PR [#874](https://github.com/Psychotoxical/psysonic/pull/874)**
@@ -690,6 +590,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Cover art — Windows thumbnails, PNG decode, and Subsonic cover ids
**By [@cucadmuh](https://github.com/cucadmuh), PR [#878](https://github.com/Psychotoxical/psysonic/pull/878)**
@@ -702,6 +603,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Analytics — advanced library backfill without webview jank
**By [@cucadmuh](https://github.com/cucadmuh), PR [#881](https://github.com/Psychotoxical/psysonic/pull/881)**
@@ -712,6 +614,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Analytics — advanced backfill scan no longer replays the first chunk
**By [@cucadmuh](https://github.com/cucadmuh), PR [#882](https://github.com/Psychotoxical/psysonic/pull/882)**
@@ -720,6 +623,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Analytics — Opus waveform and loudness analysis
**By [@cucadmuh](https://github.com/cucadmuh), PR [#883](https://github.com/Psychotoxical/psysonic/pull/883)**
@@ -728,6 +632,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Settings — Linux text-input freeze workaround
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#884](https://github.com/Psychotoxical/psysonic/pull/884)**
@@ -736,6 +641,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Artist page — top track thumbnails
**By [@cucadmuh](https://github.com/cucadmuh), PR [#886](https://github.com/Psychotoxical/psysonic/pull/886)**
@@ -745,6 +651,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Covers — load on Windows when the server URL has a `:port`
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#889](https://github.com/Psychotoxical/psysonic/pull/889)**
@@ -754,6 +661,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Library browse & covers — scroll stability and cover loading
**By [@cucadmuh](https://github.com/cucadmuh), PR [#890](https://github.com/Psychotoxical/psysonic/pull/890)**
@@ -764,6 +672,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Queue — new tracks no longer render as blank placeholders
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#892](https://github.com/Psychotoxical/psysonic/pull/892)**
@@ -773,6 +682,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Radio — track info in OS media controls
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by agriffit79 on GitHub, PR [#924](https://github.com/Psychotoxical/psysonic/pull/924)**
@@ -781,6 +691,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Advanced Search — centered button label
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#925](https://github.com/Psychotoxical/psysonic/pull/925)**
@@ -789,12 +700,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### CI — npmDepsHash on app-v* tags
**By [@cucadmuh](https://github.com/cucadmuh), PR [#927](https://github.com/Psychotoxical/psysonic/pull/927)**
* Channel publish now refreshes `nix/upstream-sources.json` and `flake.lock` on the channel branch **before** cutting `app-v*` tags, so Nix builds from release tags no longer fail with stale `npmDepsHash` (e.g. after promote finalizes `package-lock.json` version).
### Queue — Infinite Queue and Smart Radio top-ups no longer show `…` / `0:00`
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#930](https://github.com/Psychotoxical/psysonic/pull/930)**
@@ -802,6 +715,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Tracks added automatically by **Infinite Queue** or by **Smart Radio** could render as `…` / `0:00` instead of their real title and duration when the queue was filled without a queue-replacing playback (single-track enqueue from a song row, search result, etc).
* Same root cause as PR #892 — just on the auto-add paths the earlier fix did not cover. The owning server is now pinned before each auto-top-up so the resolver cache sees the fresh tracks.
### Performance — idle Rust CPU, probe overlay, and cover prefetch
**By [@cucadmuh](https://github.com/cucadmuh), PR [#939](https://github.com/Psychotoxical/psysonic/pull/939)**
@@ -811,6 +725,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Background polls:** Settings → Storage hot-cache poll 15s; cover registry full disk stats every 30s when idle instead of every 1.5s tick.
* **Cover art:** restore lazy route prefetch; batch disk peek before ensure so cached WebP warms `diskSrcCache` without flooding invoke slots; yield when viewport ensures are queued.
### Player stats — paused time no longer counts as listening time
**By [@cucadmuh](https://github.com/cucadmuh), PR [#942](https://github.com/Psychotoxical/psysonic/pull/942)**
@@ -818,6 +733,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Pausing a track and resuming later inflated the listening time in **Statistics → Player stats** — the whole paused span was billed as if the track had been playing.
* Root cause: the session's tick baseline froze on pause, so the first progress tick after resume measured against the pre-pause timestamp. It now settles the played segment on pause and rebaselines on resume.
### Cover backfill — idle CPU spin and offline & cache menu spikes
**By [@cucadmuh](https://github.com/cucadmuh), PR [#943](https://github.com/Psychotoxical/psysonic/pull/943)**
@@ -827,6 +743,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Settings → offline & cache** caused periodic CPU spikes: the section re-walked each server's full cover directory every 15s. The per-server walk is now memoized with a short TTL and reused by the stats/progress commands; the menu recomputes on entry and via progress/cache-cleared events, with a 5-minute safety poll instead of a tight 15s loop.
* Transient download failures (network / 5xx / 429) retry up to 3× with exponential backoff; permanent 4xx settle without re-scanning.
### Cover art — per-song over-fetch on Navidrome (album/mf-* explosion)
**By [@cucadmuh](https://github.com/cucadmuh), PR [#944](https://github.com/Psychotoxical/psysonic/pull/944)**
@@ -835,6 +752,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* It now treats a release as multi-disc only when each disc has a single consistent cover that differs across discs (a genuine box set); per-song ids collapse to one cover per album (≈ albums + artists). Fixed on both the Rust backfill path and the on-demand TS `albumHasDistinctDiscCovers`.
* Failed cover downloads are now logged with the album/artist name and the server error (e.g. `fetch failed for album "X" — Artist (coverArtId=…): cover HTTP 503`). Backfill failures log at the normal level; incidental on-demand misses stay at the debug level.
### Cover backfill — follow the local/public endpoint switch
**By [@cucadmuh](https://github.com/cucadmuh), PR [#952](https://github.com/Psychotoxical/psysonic/pull/952)**
@@ -842,12 +760,129 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* On a dual-address server, library cover backfill was configured once with a snapshot of the connect URL and never followed the smart LAN↔public switch. Starting already off the LAN — or moving off it mid-session — (internet up, playback already on the public address) left backfill hammering the now-unreachable local address and flooding the log with `error sending request` failures.
* The backfill worklist no longer carries a URL: each cover fetch now reads the current reachable address live, so a LAN↔public flip is honoured even by the pass already in flight (its remaining covers download against the new endpoint). The connect cache is observable and pushes the resolved URL to the native worker on every flip; a real change clears the stale `.fetch-failed` backoff and runs a forced pass so the handful of covers attempted against the old address retry on the reachable one. This also covers the boot case where the initial pass starts on the primary URL before the first reachability probe resolves. On-demand UI / playback covers already followed the switch.
### UI polish — focus rings, search fields, column menus, settings
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#954](https://github.com/Psychotoxical/psysonic/pull/954)**
* A pass of UI/CSS fixes: keyboard focus rings now sit inside the focused element, so they're no longer clipped at the edge of cards, rails, the player bar, queue tabs or search fields; the page, Help and Settings search fields share one consistent shape and focus highlight; the column-visibility dropdown on track tables no longer gets cut off on short lists (e.g. a single favorited song); and the Theme settings list rounds its corners to match its section.
### Player — prefs survive restart when queue persist hits quota
**By [@cucadmuh](https://github.com/cucadmuh), reported by norp on the Psysonic Discord, PR [#958](https://github.com/Psychotoxical/psysonic/pull/958)**
* Volume, repeat mode, queue panel visibility, and the Last.fm loved-track cache no longer depend on the quota-bound `psysonic-player` blob (full `queueItems` since thin-state #872). Each pref now has its own small localStorage key with legacy migration from the old blob.
* Startup no longer overwrites saved prefs before Zustand rehydration finishes; persisted volume is pushed to the Rust engine on boot.
### All Albums — genre filter respects sidebar library scope
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#959](https://github.com/Psychotoxical/psysonic/pull/959)**
* With multiple music libraries, narrowing the sidebar to one library no longer leaves the Genre filter showing server-wide genres — options now come from the scoped local index catalog (same scope as the album grid).
### Now Playing — multi-artist links and About the Artist tabs
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#960](https://github.com/Psychotoxical/psysonic/pull/960)**
* Tracks with OpenSubsonic `artists[]` (e.g. Navidrome `feat.` splits) now expose per-artist links on the Now Playing hero and in the queue current-track row — same interaction as player bar and album track lists.
* About the Artist loads bio for each performer; when multiple artist ids are present, tabs switch between their bios, images, and similar artists instead of showing one joined name with a single profile.
### Composers — page search keeps role-split credits
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#961](https://github.com/Psychotoxical/psysonic/pull/961)**
* Scoped search on Composers no longer replaces the Navidrome role-scoped catalog with generic artist index/search3 hits that merge split composer credits into one joined name and id — results stay split like the scroll overview.
### Browse grids — multi-select ring no longer clips (WebKitGTK)
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#962](https://github.com/Psychotoxical/psysonic/pull/962)**
* Multi-select rings on Artists, All Albums, Playlists, and related card grids use an inset `::after` overlay (same approach as card focus rings) instead of `outline` on `overflow: hidden` tiles — fixes top-row clipping and the ~1px gap vs the inner border on Wayland/WebKitGTK.
### Composers — hide performer-only artists from role catalog
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#963](https://github.com/Psychotoxical/psysonic/pull/963)**
* Navidrome's composer role list can include artists with zero composer album credits (e.g. Apollo 440 with performer albums only). Composers browse/search now drops rows where `stats.composer.albumCount` is zero so ghost composer cards no longer appear.
### Mainstage — Because you listened respects sidebar library
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#964](https://github.com/Psychotoxical/psysonic/pull/964)**
* The recommendation rail picks albums from Last.fm similar artists via `getArtist`, which can ignore `musicFolderId` — picks are now filtered to the scoped library album set, and the rail cache invalidates when the sidebar library filter changes.
### Build a Mix — keyword blocks and scoped genre list
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#965](https://github.com/Psychotoxical/psysonic/pull/965)**
* Random Mix keyword filter (click-to-block artist/genre) now applies even when "Exclude audiobooks" is off — blocking the only track in a library shows an empty state after Remix instead of the excluded song.
* Genre Mix loads genres through the scoped catalog (`fetchGenreCatalog` / local index) instead of server-wide `getGenres`, matching the sidebar library filter.
### Artist detail — external link buttons keep border on hover
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#966](https://github.com/Psychotoxical/psysonic/pull/966)**
* Last.fm, Wikipedia, and Favorite used a hover border color that matched the card background — the rim disappeared instead of highlighting the inner fill like Play/Shuffle/Radio (`btn-surface`).
* Playlist detail — Play and Add Songs now show tooltips like the other header actions; track count uses proper pluralization (`1 song` vs `N songs`) with standard `count` interpolation.
* Suggested Songs rows now render BPM (and other optional columns like genre, play count, last played) — the column switch was missing those cases.
### Player transport — custom delay input validation
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#967](https://github.com/Psychotoxical/psysonic/pull/967)**
* Absurd custom minute values (e.g. eleven nines) no longer arm an immediate timer while the preview still shows a far-future start time — input is capped to the browser delay limit and Apply stays disabled when out of range.
* Fractional custom minutes (e.g. `0.1`, `0.01`) now share the same delay math between the modal preview, armed timer, and play-button countdown so the displayed remaining time matches when playback starts or pauses.
### Settings — in-page search coverage and junk-query filtering
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#968](https://github.com/Psychotoxical/psysonic/pull/968)**
* AudioMuse-AI (Navidrome) is indexed and selectable from settings search; choosing it opens Servers and scrolls to the plugin toggle when shown.
* In-app and global shortcut labels (e.g. Volume up / Volume down) appear as search hits and focus the parent shortcuts subsection.
* Nonsense queries no longer return unrelated fuzzy matches (e.g. long repeated letters).
### Floating player bar — remove full-width background strip
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#969](https://github.com/Psychotoxical/psysonic/pull/969)**
* Floating mode no longer stretches the player bar between sidebar and queue with fixed `left`/`right` — only the centered pill is painted over the page instead of a full-width black band behind the rounded corners.
### Smart Playlist editor — themed sort, stable toggles, exclude-all genres
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#970](https://github.com/Psychotoxical/psysonic/pull/970)**
* Sort dropdown uses the themed `CustomSelect` instead of a native `<select>` whose option list followed system styling.
* Include/Exclude genre and year-range mode buttons no longer jump ~1px when selected — matched button box model and disabled hover translate on mode toggles.
* Selected genres are color-coded (primary for include, danger for exclude) so they are distinguishable from available chips.
* Excluding all genres collapses to a single untagged-genre rule instead of hundreds of `notContains` filters that stalled Navidrome; empty smart playlists settle without a false "Playlist not found" after a long spinner.
### Random Mix — audiobook exclusion no longer drops normal music
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#973](https://github.com/Psychotoxical/psysonic/pull/973) — reported by zunoz on Discord**
@@ -855,6 +890,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* "Exclude audiobooks & radio plays" no longer treats **Thriller** and **Fantasy** as audiobook keywords. They matched regular music (Trance/Metal genre tags, a track titled "Thriller") because the filter scans genre, title, album and artist, so a handful of legitimate songs were dropped from each mix.
* The exclusion's toggle area is tightened so only the checkbox and its title respond to a click — the description text and surrounding empty space no longer toggle it.
### Cursors and Favorites sorting
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#974](https://github.com/Psychotoxical/psysonic/pull/974) — reported by zunoz on Discord**
@@ -862,6 +898,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The queue collapse handle now shows a hand cursor like every other button; the thin resize line beside it keeps the resize cursor.
* On Favorites, the **Plays**, **Last Played** and **BPM** columns are now actually sortable — they showed a clickable cursor but clicking did nothing.
### Mainstage — no more blank start page
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#975](https://github.com/Psychotoxical/psysonic/pull/975)**
@@ -869,6 +906,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Hiding **Mainstage** from the sidebar no longer leaves the app opening on a blank page — it now starts on the first visible library entry instead.
* When every Mainstage section is turned off, the page shows a short message with a shortcut into **Settings → Personalisation** rather than appearing empty.
### Tracks — spacing, Duration column, header hover, multi-artist links
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#976](https://github.com/Psychotoxical/psysonic/pull/976)**
@@ -878,6 +916,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The track-list column header keeps its background on hover instead of turning transparent and letting rows show through.
* **Track of the moment** and the browse rows split multi-artist tracks into individually clickable artist links, matching the album track list.
### Queue, Genre cards, and the Artists index
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#977](https://github.com/Psychotoxical/psysonic/pull/977)**
@@ -886,12 +925,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Album cards on a **Genre** page split multi-artist credits into individually clickable artist links, matching the rest of the app.
* On the **Artists** page the `#` index button now holds only names that start with a number; accented and non-Latin names (Æ Ø Å, Chinese, Japanese, Cyrillic, …) move to a new **Other** section instead of the `#` catch-all.
### Artist detail — credit on "Also featured on" compilations
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#979](https://github.com/Psychotoxical/psysonic/pull/979)**
* Compilation albums under **Also featured on** show their album artist (e.g. *Various Artists*) again instead of a bare `—`, and the credit links to the artist when the server provides one.
### Playlist — Suggested Songs row matches the playlist
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#980](https://github.com/Psychotoxical/psysonic/pull/980)**
@@ -899,6 +940,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Suggested Songs now show the favorite heart and star rating like the playlist above, so the Favorite/Rating columns no longer leave an empty gap.
* Tracks with several artists split into individually clickable names, matching the rest of the app — and reading the same before and after you add the track.
### Small windows — usable layout when scaled down
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#981](https://github.com/Psychotoxical/psysonic/pull/981)**
@@ -907,18 +949,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The minimum window size is a touch larger so the layout can no longer be shrunk past the point where it breaks.
* On a short window the Now Playing cover scales down to fit instead of overlapping the track title.
### Song rails — consistent navigation buttons
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#982](https://github.com/Psychotoxical/psysonic/pull/982)**
* The song rail's previous/next and reroll buttons are now square like every other rail instead of round.
### Local index live search — no junk hits on `=` and syntax characters
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#983](https://github.com/Psychotoxical/psysonic/pull/983)**
* Queries such as `1=2` or `M=c` no longer return unrelated albums and artists — FTS5 was parsing `=` and similar characters as query syntax instead of a literal token.
* Wildcard-only queries (`**`, `****`) are rejected for both local index and server search; titles that contain censorship stars (e.g. `***Flawless`) remain searchable.
### Search — song results show their album cover
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#984](https://github.com/Psychotoxical/psysonic/pull/984)**
* Song results in the search dropdown now show their album cover instead of leaving most thumbnails blank.
### Mainstage album rails — stable New badge on first hover
**By [@cucadmuh](https://github.com/cucadmuh), PR [#986](https://github.com/Psychotoxical/psysonic/pull/986)**
* Horizontal album rails (Home New Releases, Discover, Favorites, Statistics, search rows, …) no longer hide the **New** / offline cover badges during hover zoom — cover stacking is shared with grid pages; rails keep dim-on-`::before` for play controls.
### Player bar — stable waveform when showing remaining time
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#987](https://github.com/Psychotoxical/psysonic/pull/987)**
* **Show remaining time** no longer reticks the seekbar width every second — fixed-width playbar clocks stop `WaveformSeek` resize/redraw jitter; clocks sit tighter against the waveform with an inline duration toggle icon.
## [1.46.0] - 2026-05-18
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
-1
View File
@@ -30,7 +30,6 @@ import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFo
import { usePreviewStore } from '../store/previewStore';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { formatTrackTime } from '../utils/format/formatDuration';
import { PlaybackTime, RemainingTime } from './playerBar/PlaybackClock';
import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo';
import { PlayerTransportControls } from './playerBar/PlayerTransportControls';
import { PlayerSeekbarSection } from './playerBar/PlayerSeekbarSection';
+40 -16
View File
@@ -1,35 +1,59 @@
import { memo, useEffect, useRef } from 'react';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress } from '../../store/playbackProgress';
import { formatTrackTime } from '../../utils/format/formatDuration';
import {
formatPlaybarClock,
formatPlaybarToggleClock,
formatTrackTime,
} from '../../utils/format/formatDuration';
/** Renders the playback clock without ever causing PlayerBar to re-render.
* Updates the DOM directly via an imperative store subscription. */
export const PlaybackTime = memo(function PlaybackTime({ className }: { className?: string }) {
export const PlaybackTime = memo(function PlaybackTime({
className,
minuteFieldWidth,
}: {
className?: string;
minuteFieldWidth?: number;
}) {
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const format = (seconds: number) =>
minuteFieldWidth != null
? formatPlaybarClock(seconds, minuteFieldWidth)
: formatTrackTime(seconds);
if (spanRef.current) {
spanRef.current.textContent = formatTrackTime(getPlaybackProgressSnapshot().currentTime);
spanRef.current.textContent = format(getPlaybackProgressSnapshot().currentTime);
}
return subscribePlaybackProgress(state => {
if (spanRef.current) spanRef.current.textContent = formatTrackTime(state.currentTime);
if (spanRef.current) spanRef.current.textContent = format(state.currentTime);
});
}, []);
}, [minuteFieldWidth]);
return <span className={className} ref={spanRef} />;
});
/** Renders the remaining time (duration - currentTime) without causing PlayerBar
* to re-render. */
export const RemainingTime = memo(function RemainingTime({ duration, className }: { duration: number; className?: string }) {
/** Remaining/duration toggle clock — fixed-length string, imperative updates. */
export const ToggleClock = memo(function ToggleClock({
duration,
minuteFieldWidth,
remaining,
className,
}: {
duration: number;
minuteFieldWidth: number;
remaining: boolean;
className?: string;
}) {
const spanRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const updateRemaining = () => {
if (spanRef.current) {
const remaining = Math.max(0, duration - getPlaybackProgressSnapshot().currentTime);
spanRef.current.textContent = `-${formatTrackTime(remaining)}`;
}
const update = () => {
if (!spanRef.current) return;
const seconds = remaining
? Math.max(0, duration - getPlaybackProgressSnapshot().currentTime)
: duration;
spanRef.current.textContent = formatPlaybarToggleClock(seconds, minuteFieldWidth, remaining);
};
updateRemaining();
return subscribePlaybackProgress(updateRemaining);
}, [duration]);
update();
return remaining ? subscribePlaybackProgress(update) : undefined;
}, [duration, minuteFieldWidth, remaining]);
return <span className={className} ref={spanRef} />;
});
@@ -2,9 +2,9 @@ import { ArrowLeftRight } from 'lucide-react';
import type { TFunction } from 'i18next';
import type { RadioMetadata } from '../../hooks/useRadioMetadata';
import { useThemeStore } from '../../store/themeStore';
import { formatTrackTime } from '../../utils/format/formatDuration';
import { formatTrackTime, playbarMinuteFieldWidth } from '../../utils/format/formatDuration';
import WaveformSeek from '../WaveformSeek';
import { PlaybackTime, RemainingTime } from './PlaybackClock';
import { PlaybackTime, ToggleClock } from './PlaybackClock';
interface Props {
isRadio: boolean;
@@ -21,8 +21,14 @@ export function PlayerSeekbarSection({
isRadio, radioMeta, trackId, duration, localShowRemaining, setLocalShowRemaining,
disableWaveformCanvas, t,
}: Props) {
const minuteFieldWidth = playbarMinuteFieldWidth(duration);
const playbarClockStyle = {
'--playbar-clock-body-ch': minuteFieldWidth + 3,
'--playbar-clock-signed-ch': minuteFieldWidth + 4,
} as React.CSSProperties;
return (
<div className="player-waveform-section">
<div className="player-waveform-section" style={playbarClockStyle}>
{isRadio ? (
<>
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? (
@@ -50,7 +56,7 @@ export function PlayerSeekbarSection({
</>
) : (
<>
<PlaybackTime className="player-time" />
<PlaybackTime className="player-time" minuteFieldWidth={minuteFieldWidth} />
<div className="player-waveform-wrap">
{disableWaveformCanvas
? <div className="radio-progress-bar" aria-hidden />
@@ -65,8 +71,13 @@ export function PlayerSeekbarSection({
}}
data-tooltip={localShowRemaining ? t('player.showDuration') : t('player.showRemainingTime')}
>
{localShowRemaining ? <RemainingTime duration={duration} /> : formatTrackTime(duration)}
<ArrowLeftRight size={10} style={{ marginLeft: 4, opacity: 0.6 }} />
<ToggleClock
className="player-time-toggle__label"
duration={duration}
minuteFieldWidth={minuteFieldWidth}
remaining={localShowRemaining}
/>
<ArrowLeftRight className="player-time-toggle__icon" size={10} aria-hidden />
</span>
</>
)}
+23 -3
View File
@@ -279,7 +279,7 @@ html[data-platform="windows"] .player-bar.floating {
min-width: 0;
display: flex;
align-items: center;
gap: var(--space-2);
gap: 4px;
margin-left: 24px;
margin-right: 24px;
}
@@ -298,7 +298,10 @@ html[data-platform="windows"] .player-bar.floating {
min-width: 2.5rem;
}
.player-time:first-child {
.player-waveform-section > .player-time:first-child {
flex: 0 0 calc(var(--playbar-clock-body-ch, 4) * 1ch);
width: calc(var(--playbar-clock-body-ch, 4) * 1ch);
min-width: unset;
text-align: right;
}
@@ -307,15 +310,32 @@ html[data-platform="windows"] .player-bar.floating {
}
/* Clickable time toggle with swap indicator */
.player-time.player-time-toggle {
min-width: unset;
}
.player-time-toggle {
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 2px 4px;
gap: 2px;
flex-shrink: 0;
padding: 2px 0 2px 2px;
border-radius: 4px;
transition: background-color 0.15s, color 0.15s;
}
.player-time-toggle__label {
flex: 0 0 calc(var(--playbar-clock-signed-ch, 5) * 1ch);
width: calc(var(--playbar-clock-signed-ch, 5) * 1ch);
font-variant-numeric: tabular-nums;
}
.player-time-toggle__icon {
flex-shrink: 0;
opacity: 0.6;
}
.player-time-toggle:hover {
background-color: var(--surface-hover, rgba(128, 128, 128, 0.2));
color: var(--text-primary);
+49 -1
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { formatTrackTime, formatLongDuration } from './formatDuration';
import {
formatTrackTime,
formatLongDuration,
formatPlaybarClock,
formatPlaybarToggleClock,
playbarMinuteFieldWidth,
PLAYBAR_CLOCK_PAD,
} from './formatDuration';
describe('formatTrackTime', () => {
it('formats m:ss with zero-padded seconds', () => {
@@ -51,3 +58,44 @@ describe('formatLongDuration', () => {
expect(formatLongDuration(-1)).toBe('0:00');
});
});
describe('playbarMinuteFieldWidth', () => {
it('matches the minute digit count of the track duration', () => {
expect(playbarMinuteFieldWidth(65)).toBe(1);
expect(playbarMinuteFieldWidth(599)).toBe(1);
expect(playbarMinuteFieldWidth(600)).toBe(2);
expect(playbarMinuteFieldWidth(3600)).toBe(2);
expect(playbarMinuteFieldWidth(6000)).toBe(3);
});
it('returns 1 for invalid duration', () => {
expect(playbarMinuteFieldWidth(0)).toBe(1);
expect(playbarMinuteFieldWidth(NaN)).toBe(1);
});
});
describe('formatPlaybarClock', () => {
it('pads minutes with figure spaces to a fixed field width', () => {
expect(formatPlaybarClock(65, 1)).toBe('1:05');
expect(formatPlaybarClock(599, 2)).toBe(`${PLAYBAR_CLOCK_PAD}9:59`);
expect(formatPlaybarClock(600, 2)).toBe('10:00');
});
it('keeps remaining/elapsed strings the same length while ticking', () => {
expect(formatPlaybarClock(599, 2).length).toBe(formatPlaybarClock(600, 2).length);
expect(formatPlaybarClock(59, 2).length).toBe(formatPlaybarClock(60, 2).length);
});
it('returns a padded zero clock for invalid input', () => {
expect(formatPlaybarClock(0, 2)).toBe(`${PLAYBAR_CLOCK_PAD}0:00`);
expect(formatPlaybarClock(NaN, 3)).toBe(`${PLAYBAR_CLOCK_PAD.repeat(2)}0:00`);
});
});
describe('formatPlaybarToggleClock', () => {
it('prefixes remaining with minus and duration with a figure space', () => {
expect(formatPlaybarToggleClock(65, 1, true)).toBe('-1:05');
expect(formatPlaybarToggleClock(65, 1, false)).toBe(`${PLAYBAR_CLOCK_PAD}1:05`);
expect(formatPlaybarToggleClock(65, 1, true).length).toBe(formatPlaybarToggleClock(65, 1, false).length);
});
});
+40
View File
@@ -21,3 +21,43 @@ export function formatLongDuration(seconds: number): string {
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/** Invisible same-width pad for fixed-length playbar clocks (no leading zeros). */
export const PLAYBAR_CLOCK_PAD = '\u2007';
/** Minute digit count for a fixed-width playbar clock derived from track duration. */
export function playbarMinuteFieldWidth(durationSeconds: number): number {
if (!durationSeconds || !isFinite(durationSeconds) || durationSeconds <= 0) return 1;
return Math.max(1, String(Math.floor(durationSeconds / 60)).length);
}
/**
* Fixed-width `m:ss` for the player seekbar minutes padded with figure spaces
* so digit columns do not shift while the clock ticks (WebKit waveform resize).
*/
export function formatPlaybarClock(
seconds: number,
minuteFieldWidth: number,
fallback = '0:00',
): string {
if (!seconds || !isFinite(seconds) || seconds < 0) {
if (fallback === '0:00') {
return `${PLAYBAR_CLOCK_PAD.repeat(Math.max(0, minuteFieldWidth - 1))}0:00`;
}
return fallback;
}
const clamped = Math.max(0, seconds);
const m = Math.floor(clamped / 60);
const s = Math.floor(clamped % 60);
return `${String(m).padStart(minuteFieldWidth, PLAYBAR_CLOCK_PAD)}:${s.toString().padStart(2, '0')}`;
}
/** Right-hand playbar toggle: `-m:ss` (remaining) or figure-space + `m:ss` (duration). */
export function formatPlaybarToggleClock(
seconds: number,
minuteFieldWidth: number,
remaining: boolean,
): string {
const body = formatPlaybarClock(seconds, minuteFieldWidth);
return remaining ? `-${body}` : `${PLAYBAR_CLOCK_PAD}${body}`;
}