Under the Hood — Refactoring & Test Suite
By @cucadmuh + @Psychotoxical
Alongside the user-facing changes, this release closes out a large engineering effort that touched almost every corner of the codebase. None of it changes how Psysonic behaves — it changes how fast and how safely the next features can land.
- Backend → Cargo workspace. The Rust backend was lifted out of a single crate into five focused domain crates — audio, analysis, sync/offline, integrations and core. See Backend — Cargo workspace with 5 domain crates under Changed below for the full breakdown.
- Frontend modularization. The largest "god-module" components, stores and stylesheets were broken into small, single-purpose files, shared helpers were deduplicated, and the i18n locale and CSS bundles were split per namespace. See Frontend — large modules split into focused files under Changed below for the full breakdown.
- Automated test suite. Psysonic now ships with a real test suite on both sides —
cargo testacross the Rust workspace and a Vitest suite on the frontend — backed by per-file coverage gates in CI that block a merge when a hot-path file regresses below threshold. Playback, queue, auth, the offline cache, the API layer and the core UI components are now pinned by characterization tests.
Foundational work: faster reviews, narrower diffs, and a safety net under the parts of the app that matter most.
Added
HTTP — gzip + brotli decompression for the Rust-side clients
By @Psychotoxical, PR #704
- Every HTTP client on the Rust side now advertises
Accept-Encodingand transparently decodes compressed responses. The JSON-heavy endpoints — Navidrome native/api, Bandsintown, Radio-Browser, Last.fm — were the gap: the WebView'saxioscalls were already compressed, the Rust clients were not. Earlier curl measurements put the wire savings on those payloads at roughly 76–93 %. - Implementation is a dependency-feature change only (
gzip+brotlionreqwest) — no behaviour change beyond smaller transfers, and no extra cost on the clients that fetch already-compressed audio.
Queue Toolbar — customizable button order + per-button visibility
- Settings → Personalisation grows a new Queue Toolbar section. Drag-and-drop reorders the toolbar buttons; a per-button toggle hides individual entries; a Separator item can be placed anywhere to break the row into visual groups. A Reset button restores the default layout.
- Persistence via a new
queueToolbarStore(Zustand + localStorage), so the layout survives restarts. - Behaviour-preserving default:
[Shuffle] [Save] [Load] [Share] [Clear] | [Gapless] [Crossfade] [Infinite]— same buttons in the same order as before. - Auto-hides the toolbar when no real button is visible (a lone Separator no longer takes up space on its own).
- i18n coverage across all 9 locales.
Orbit — in-app diagnostics popover with copyable event log
By @Psychotoxical, prompted by reports from nzxl + RavingGrob, PR #524
- New Activity-icon button in the Orbit session bar opens a diagnostics popover. Live mini-display (role, host vs. guest track, position, drift, state-age) updates once a second; below it, a scrolling event log textarea is fed by a 200-entry in-memory ring buffer.
- Copy + Clear buttons. Copy drops formatted
[ISO] [scope] bodylines on the clipboard — paste straight into a Discord bug report. - Instrumentation lands at every previously-silent decision point in the guest tick (
initial-sync,track-changefollowed / diverged,play-pause-flip) plus host state pushes, so the "stopped after the first song" Orbit symptom is now diagnosable from the buffer alone. - Events are also bridged to the existing
frontend_debug_logcommand when Settings → Logging is on Debug, so power users still get the same data inpsysonic-logs-*.logfor offline triage. - i18n: full
orbit.diag.*namespace across all 9 locales (EN + DE native; RO native; ES / FR / NB / NL / RU / ZH first-pass, polish welcome).
Player Bar — album context menu on song title right-click
By @Psychotoxical, PR #512
- Right-clicking the track title in the player bar now opens the same album context menu that album cards expose — open, play next, enqueue, go to artist, favorite, rate, share, download, add to playlist.
- Mirrors the existing left-click on the title (which already navigates to the album) and is suppressed during radio playback and previews.
Settings — OpenDyslexic font option for dyslexic readers
By @Psychotoxical, PR #507
- Next step on the accessibility track after the colour-side work (WCAG contrast audits across every theme, dedicated colour-vision-deficiency theme variants for protanopia / deuteranopia / tritanopia): a dyslexia-friendly OpenDyslexic font option in the existing font picker.
- OpenDyslexic uses a heavier weighted baseline and asymmetric glyph shapes —
b/d,p/qnever mirror, italics are differentiated forms rather than slanted regulars — which many dyslexic readers find easier to track than a typical sans. - Bundled locally via
@fontsource/opendyslexic(SIL OFL, freely redistributable). No CDN dependency. The Settings picker grew an optional subtitle field on font entries so the OpenDyslexic row carries a "dyslexia-friendly · no RU/ZH support" hint without cluttering the other 14 fonts. - Latin, Latin-extended and Cyrillic. Chinese (ZH) falls back to the system font when OpenDyslexic is selected; the subtitle calls that out upfront. Subtitle text is translated in all 9 locales.
Lossless Albums — rail on Home + dedicated page + sidebar entry
By @Psychotoxical, PR #506
- New Lossless Albums browse mode: a rail under "Most Played" on Home and a dedicated infinite-scroll
/lossless-albumspage with full Albums-page header parity (selection mode + Enqueue / Add Offline / Download ZIPs). - Detection walks Navidrome's native
/api/song?_sort=bit_depth&_order=DESCand dedupes to album ids along the way, stopping when the cursor crosses into lossy or runs out. Restricted to containers that are always lossless (flac,wav,aiff/aif,dsf/dff,ape,wv,shn,tta) —m4aandwmaare intentionally excluded because they can carry both lossless and lossy codecs and Navidrome'scodecfield isn't reliable enough to disambiguate. - Streaming load: albums stream into the page progressively as each internal fetch completes (
onProgresscallback) instead of blocking on the fullloadMore. - New sidebar entry Lossless (Gem icon), visible by default.
- i18n coverage across all 9 locales for the new strings (sidebar / home / page subtitle / empty / unsupported). RU and ZH are machine-translation quality, flagged for a polish pass.
Song Info — absolute file path on Navidrome servers
By @Psychotoxical, suggested by volcs0, PR #504
- The Path row in the Song Info dialog now shows the absolute server-side filesystem path of a track on Navidrome servers. Subsonic's
getSongonly ever returned a relative path (or nothing at all on Navidrome), which is why the row was effectively empty before. A new native-API call (/api/song/{id}) runs in parallel withgetSong; on non-Navidrome servers the dialog falls back to whatever the Subsonic response carried.
Discord — album cover art from your own server
- Discord Rich Presence can now show album artwork from your own server via the Subsonic
getAlbumInfo2endpoint (requires the server to be publicly reachable). - New cover-source picker under Discord Rich Presence settings: None (app icon only), Server, or Apple Music. Mutually exclusive.
- Fresh installs default to Server for opt-in-friendly cover art with no third-party data leak. Existing users keep their previous Apple-covers preference via migration.
Queue — preserve "Play Next" insertion order (toggle)
By @Psychotoxical, suggested by @Sayykii, PR #464
- New optional toggle in Settings → Audio → Playback ("Preserve Play Next order"). When on, multiple "Play Next" insertions queue up behind each other instead of the latest one bumping earlier picks down. Default off — existing behaviour unchanged.
- Side-benefit: single-song "Play Next" now goes through the unified
enqueueAtpath and gets undo + server-sync support that the album path already had.
Library — "favorites only" filter on Albums, Artists and Advanced Search
By @Psychotoxical, suggested by @lilgringo, PR #466
- New star-toggle button in the toolbars of Albums, Artists and Advanced Search that flips the visible list to favourites-only.
- Filter state is ephemeral per page (not persisted) so users don't come back to a half-empty library and wonder where their content went.
- Reads star state live from in-memory overrides — toggling a favourite from a context menu updates the visible list immediately, no refetch.
Search — artist photos in live and mobile results
- Live search and the mobile search overlay now load artist images for rows in the Artists section via the same
getCoverArt/ image-cache path as album art (coverArtwhen present, otherwise the artist id where the server supports it), with a fallback icon when art is missing or fails. - Mobile artist hits use a round thumbnail next to square album art so the two result types read clearly at a glance.
Artist page — group albums by release type
- Albums on the artist page can now be grouped into sections by their OpenSubsonic
releaseTypes(Album, EP, Single, Compilation, Live, Soundtrack, Remix). Section order is deterministic across languages, with unknown types appended at the end. - Falls back to the previous flat list when the server doesn't return
releaseTypesor all albums share the default Album type — no behaviour change for non-OpenSubsonic servers. - Section headers are localised in all 9 supported languages.
Library — Browse by Composer
By @Psychotoxical, suggested by mmourez (issue #465), PR #487
- New Composers library section listing every artist credited as composer on at least one track, with a detail page showing all works they hold in that role. Aimed at classical-music libraries where the recording artist is the orchestra and the composer tag carries Bach / Mozart / Chopin.
- Uses Navidrome's native API (
/api/artist?_filters={"role":"composer"}for the listing,/api/album?_filters={"role_composer_id":"…"}for the works) — SubsonicgetArtistonly walks AlbumArtist relations and returns zero albums for composer-only credits, so the native path is the only one that works. Requires Navidrome 0.55+; older / pure-Subsonic servers see a one-line capability banner. - Music-folder scope is honoured: role queries pass Navidrome's
library_idfilter so per-folder browsing matches the Albums / Artists pages. Bio + Last.fm portrait stay stable across scope changes. - Composers are a first-class share entity.
psysonic2-links withk=composerpaste to/composer/:id; the Share button on the detail page and the right-click menu both copy acomposerlink. - Sidebar entry is off by default (classical-music use case is a niche) — toggle in Settings → Sidebar.
Home — "Because you listened" recommendation rail
By @Psychotoxical, PRs #489, #493
- New Home rail that surfaces albums similar to one of your favourite artists — Spotify-style "Because you listened to …" recommendations.
- Anchor pool round-robin merges Most Played, Recently Played and Favorites (deduped per artist), so the per-mount rotation lands on a different listening mode each visit instead of walking only the top-played list. Pool size 12 lets the cursor visit all three sources before wrapping. Within each anchor,
getArtistInforeturns up to 12 similar artists; the rail randomly samples 6 of them and surfaces 3 albums (one random per matching artist) that exist on your server. - Anchor rotation is per-server: switching servers keeps independent rotation state instead of aliasing one server's anchor id onto the next server's pool. The rail also renders on fresh servers that have no frequent-play history yet, as long as they have starred or recently played items. Zero extra API calls — all three seed lists are already in the Home initial fetch.
- Responsive layout: 3 cards in one row on 2K-class screens, 2 cards in one row at 1080p (the orphan third card on a second row is hidden via container query), and all 3 stacked vertically on truly narrow / mobile widths.
- Toggleable in the Home customizer like every other rail; respects the existing performance flags ("Disable rail artwork", "Disable Home album rows").
Romanian (ro) translation
By @MihaiCatalin120, PR #663
- Complete Romanian (
ro) locale for navigation, player, playlists, settings, help, and errors. - Psysonic now ships in nine UI languages: English, German, Spanish, French, Dutch, Norwegian Bokmål, Russian, Chinese (Simplified), and Romanian.
Changed
Backend — Cargo workspace with 5 domain crates (Rust refactor)
By @cucadmuh + @Psychotoxical, PR #532
- The Rust backend was lifted out of a single
psysoniccrate into a Cargo workspace with five domain crates:psysonic-core(logging + ports),psysonic-analysis(waveform + LUFS cache + admin commands),psysonic-audio(engine, decode, sources, codec, stream sub-modules, audio Tauri commands),psysonic-syncfs(offline + hot cache + downloads + USB/SD sync),psysonic-integration(Discord rich-presence, Navidrome native API, Last.fm scrobbling, internet-radio browsing, Bandsintown). The toppsysoniccrate keeps only the Tauri-shell wiring. lib.rsshrank from ~1000+ LOC to 454 LOC, retaining only Tauri-shell concerns (setup hook, plugin registration, window events, tray builder).- The Audio→Analysis circular-dependency loop is broken via two
Arc<dyn Fn(&str) -> bool>closures registered inlib.rs:setup, avoiding what would have been a 32-callsite migration toState<Arc<AudioEngine>>. - No user-visible behaviour change. Automated parity check confirmed 121/121 Tauri commands resolve identically vs. the pre-refactor tree;
cargo check --workspaceandcargo clippy --workspace --all-targetsare clean; smoke tests pass on Linux, Windows, and macOS. - Foundation work — per-domain bug fixes and features now ship with much narrower diff scope (this release's Orbit batch + waveform fixes were the first to benefit).
Frontend — large modules split into focused files (React/TypeScript refactor)
By @cucadmuh + @Psychotoxical
- The frontend counterpart to the backend workspace split: across roughly a hundred follow-up PRs, the largest "god-module" page components, stores and stylesheets were broken into small, single-purpose files. The biggest offenders —
QueuePanel,FullscreenPlayer,MiniPlayer,PlayerBar,AppShell,AlbumTrackList, the context menu, the waveform seekbar andutils/orbit.tsamong them — each went from one oversized file to a folder of focused components, hooks and helpers. - Shared logic that had been copy-pasted across call sites — duration / byte formatters, sanitizers, clock helpers, shuffle and dedupe routines — was consolidated into single
utils/helpers, and the remainingutils/files were grouped into topic folders. - The monolithic
i18nlocale files and the global CSS bundles (theme.css,components.css,layout.css,tracks.css) were split per namespace / per section, so editing one feature's strings or styles no longer means touching a multi-thousand-line file. - No user-visible behaviour change. Every step was a pure code move verified by
tsc, the Vitest suite and a production build; the new automated test suite (see Under the Hood above) was built out alongside the refactor specifically to pin behaviour while files were in motion. - Foundation work — same payoff as the backend split: feature and bug-fix diffs now land with far narrower scope, and the frontend's hottest paths are covered by tests.
Settings — collapse-by-default cleanup, font picker without dropdown, OpenDyslexic at top
By @Psychotoxical, PR #508
- Every Settings sub-section now boots collapsed. Audio Device, Lyrics Sources, Last.fm, Sidebar, Random Mix, Offline Dir, Theme, Keybindings and Language used to auto-expand on first render — each tab felt like a wall of controls before the user had even looked for something specific. Click the section header to open what you actually need.
- ThemePicker no longer auto-expands the group containing the active theme. The blue dot in the group header already surfaces which group holds it.
- Font picker lost its inner dropdown button. Opening the Font sub-section now reveals the full font list directly; one click sets the font.
- OpenDyslexic moves to the top of the font list so dyslexic readers don't scroll past 14 sans-serifs to find their option.
Settings — language picker uses endonyms
By @Psychotoxical, suggested by cucadmuh, PR #514
- The Settings language picker now shows each language written in itself —
English,Deutsch,Español,Français,Nederlands,Norsk,Русский,中文,Română— same nine labels in every locale instead of translating each name into the current UI language. - A native speaker can recognise their own language regardless of which UI language is currently active; same convention used by Wikipedia and most OS-level language pickers.
- Filled in
languageEsfor the six locales (de/fr/nl/nb/ru/zh) where it was missing — the Spanish entry was previously falling back to'Spanish'fromen.tson every non-EN/non-ES UI.
Dependencies — npm / Cargo refresh and rodio 0.22
- Bumped frontend and Tauri / Rust dependencies across the workspace (
package.json,package-lock.json,Cargo.toml,Cargo.lock). - Playback stack migrated to rodio 0.22 with corresponding updates in decode, sources, engine, device I/O and related modules.
Build — lazy-loaded routes and Vite chunk warnings
- Heavier app routes are loaded lazily so the initial JS bundle stays smaller (
App.tsxand related entry wiring). - Restored default Vite
chunkSizeWarningLimitbehaviour so oversized chunks are reported again during production builds (vite.config.ts).
UI — cover cache, mainstage rails, and smoother virtual lists
- Image cache: Network fetches share a small concurrency pool; IndexedDB hits are no longer queued behind remote downloads. Debounced disk eviction avoids hammering storage during fast cover scrolling. Related shared blob URL / hot-path fixes for thumbnails.
- Mainstage & rails: Horizontal rows use reworked artwork windowing (higher initial budgets, extra slack ahead of the viewport, no budget reset when “load more” extends the list). Duplicate album/song IDs from the API are deduped for stable React reconciliation. CachedImage handles already-decoded / cache-hit images cleanly; rail Album / song cards load covers eagerly to reduce blank art while scrubbing sideways.
- Virtualised lists: Albums, Artists (list mode), and the Tracks virtual song browser derive TanStack Virtual
overscanfrom the measured scroll viewport (~ one screen of extra rows above and below) instead of a tiny fixed cushion. - Library & chrome: assorted scroll / layout improvements on Artist detail, Playlists, Most played; smaller touch-ups to Mini player, Live search, Album header, and dynamic colour extraction used by player / album surfaces.
Covers / image cache — parallel fetch + downscale, registry guard, search slot hints
- When a pixel size misses disk but another size of the same
cover:idis already cached, remotegetCoverArtand client JPEG downscale run in parallel; the first good blob wins and the other side aborts (network + downscale signals). - Sibling key reads use one IndexedDB readonly transaction instead of many separate transactions.
COVER_ART_REGISTERED_SIZEScentralises knowngetCoverArtwidths for invalidation and sibling lookup;coverArtRegisteredSizes.test.ts(Vitest) keeps literalcoverArtCacheKey(_, n)call sites insrcin sync with that list.downscaleCoverBlobrespects AbortSignal throughcanvas.toBlob.CachedImage:fetchQueueBiasgives artist search thumbs higher network-slot priority than album thumbs when the pool is saturated;observeRootMargindefaults wider so priority updates earlier ahead of the scroll viewport.
Settings — adding a server no longer switches to it
By @Psychotoxical, PR #475
- When you add a new server from Settings → Servers, the new entry now appears in the server picker but your current active server stays active — playback, queue and library view are no longer interrupted.
- The login screen on
/loginis unchanged: signing in there still selects the chosen server.
Most Played — quick actions, real context menu, prominent plays badge
By @Psychotoxical, suggested by nzxl, PR #482
- Each album row now shows always-visible Play and Enqueue quick-action buttons, reusing the same flows as
AlbumCard(Play kicks the fade-out replace-and-play, Enqueue appends the album's songs to the queue end). - Right-click on an album row now opens the standard album context menu (Play / Add to queue / Play next / Add to playlist / Go to artist) instead of firing a hidden direct-play action; right-click on a Top Artists card opens the artist context menu.
- The play count moved from a small right-aligned column to a localized pill right next to the album title —
11 plays(en),11× gespielt(de) — since the play count is the central datum on this page.
Multi-select — Shift+Click range selection on grid pages
By @Psychotoxical, PR #484
- In multi-select mode on Albums, Random Albums, New Releases and Playlists, holding Shift while clicking a second card now selects every item between the anchor (last clicked) and the click target — the standard OS-level pattern. Range expansion follows the user-visible order, so filters and sort affect what gets included.
- Plain click still toggles a single item and moves the anchor to it; behaviour without Shift is unchanged.
Help — full rewrite with live search and 10 cleanly-themed sections
By @Psychotoxical, PR #485
- Help page rebuilt from scratch: dropped entries the UI itself answers, consolidated natural groupings, and added entries for features that didn't exist yet when the original Q/A list was written (Orbit, Magic Strings, LUFS, Mini Player, Smart Playlists, Track Preview, Search, Statistics, Tracks hub, Genre Browser, Discord, Bandsintown, Multi-select, Sidebar/Home/Artist customization, Sleep Timer, Open Source Licenses). 45 focused entries across 10 themed sections.
- New live in-page search: case-insensitive substring across every Q+A, sections without hits collapse out, matches auto-expand so the answer is visible without clicking. × button clears the query.
- Translated to all 9 supported locales (en, de, fr, nl, zh, nb, ru, es, ro). Russian and Chinese are at machine-translation quality and would benefit from a polish pass by the original locale maintainers.
Community themes — redesign pass
- Removed five themes that overlapped or felt strenuous on the eyes: Amber Night, Ice Blue, Monochrome, Phosphor Green, Rose Dark.
- Added eight new dark themes covering the colour families people most commonly ask for: Obsidian Black, Carbon Grey, Volcanic Dark, Forest Green, Violet Haze, Copper Oxide, Sakura Night, Obsidian Gold.
- Light polish on the existing AMOLED Black Pure surface variables so card surfaces no longer collapse onto a pure-black background that read as a single flat slab.
Covers / image cache — useCachedUrl tied to cacheKey; CachedImage load gate
useCachedUrlonly returns a shared blob URL when it still matches the currentcacheKey, so a track change does not paint one frame with the previous track's object URL after refcount handling (player bar, queue header cover, Now Playing / mobile paths on the hook).CachedImageclears the opacity load gate inuseLayoutEffectwhencacheKeychanges so the first paint after a swap cannot briefly show the newsrcat full opacity before the gate runs.
Removed
Settings — Animations 3-state setting under Seekbar Style
By @Psychotoxical, PR #495
- The Animations 3-state setting (Full / Reduced / Static) under Settings > Appearance > Seekbar Style is gone. The newer perf-flag system and per-feature performance work cover the expensive animation paths more directly: marquee scrolling can be disabled via
perfFlags.disableMarqueeScroll(Sidebar toggle), global CSS animations via the html-leveldata-perf-disable-animationsswitch, and seekbar performance is handled by the newer per-feature toggles. - Anyone who had
'reduced'or'static'selected silently lands on the normal animation path on first launch after upgrade — the persist layer strips the obsolete field, no user-facing prompt.
Fixed
Settings — contributors list sorted chronologically
By @Psychotoxical, PR #700
- The Settings → System → Contributors list rendered in raw insertion order, so the original maintainer (since v1.0.0) showed up last and the hand-maintained ordering drifted as new entries were appended. It is now sorted on render — ascending by the app version a contributor first appeared in, tie-broken by their first-contribution PR number — so it stays correct no matter where new entries land in the source list.
Internet Radio — Add / Edit station modal no longer clipped on empty library
By @cucadmuh, thanks to voidboywannabe for the report on the Psysonic Discord, PR #699
- Add Station / Edit now mount
RadioEditModalwithcreatePortal(..., document.body)(same layering approach as Search Directory). Previously the overlay lived under nested.content-bodywithcontain: paint, so on an empty station list the fixed overlay was painted inside a short box and looked cropped; after the first station the layout was tall enough that the bug was easy to miss.
Now Playing — stable list keys on dashboard cards (duplicate React key warnings)
- Similar artists, the track list inside the in-player album card, and Top songs can all include more than one row with the same Subsonic
id(server payloads are not always strictly deduped). Those lists usedkey={id}, which triggered duplicatekeywarnings in React devtools and made reconciliation depend on row order. Keys now combineidwith the list index — rendering only; no queue or playback change.
Search — hide duplicate artist hits with zero albums
By @cucadmuh, thanks to zunoz for the report on the Psysonic Discord, PR #697
- Subsonic
search3sometimes returns extra artist rows that duplicate a real name but list 0 albums (server-side indexing noise).search()now strips artists whosealbumCountis exactly0. Rows with noalbumCountfield are kept so strict or legacy servers are unchanged. - Applies everywhere
search()is used: header live search, mobile overlay, search results page, advanced search free-text query, and hooks that callsearch()(e.g. similarity fallbacks).
Offline downloads — the cancel button works again + the sidebar toast keeps its size
By @Psychotoxical, PR #694
- The ✕ on the sidebar download toast now actually cancels the download. It was only dropping not-yet-started tracks between batches of 8 — for an album of 8 or fewer tracks the check never ran a second time, so the click did nothing, and in-flight transfers ran to completion regardless. Cancellation now reaches the Rust side: a new
cancel_offline_downloadscommand flips a per-download flag thatdownload_track_offlinechecks after acquiring its slot and on every streamed chunk, so an in-progress transfer aborts mid-file (its.partfile is cleaned up). The frontend also drops every job for the cancelled album immediately, so the toast disappears at once instead of lingering on stuck "downloading" rows. Tracks that had already finished before the cancel are kept rather than orphaned on disk. - The download progress toast no longer gets squished when the main window is small. It lives in the sidebar's vertical flex column and was missing
flex-shrink: 0, so a short window compressed it; the label now also ellipsis-truncates on a narrow sidebar instead of overflowing.
Orbit — guest playback fixes
By @Psychotoxical, reported by nzxl + RavingGrob, PR #525
- All local queue-extension paths are suppressed for the entire Orbit session lifecycle, not just while the session is
active. Radio top-up, infinite-queue top-up, queue-exhaustion fallback, and the proactive "≤ 2 auto-tracks ahead" topper all refuse to extend the local queue while role is host or guest and phase isstarting/joining/active. Even fetch promises that were already in flight re-check at resolution time, so a click-Join racing with a fetch can't pop the bulk-add modal after the join completes. Without this lockout the guest got a "Add 5 tracks to the Orbit queue?" prompt on track-end (offering to inject unrelated suggestions into the host's shared queue) and the local queue silently drifted off the host's playlist. - Natural track-end no longer reads as "the guest manually paused" — the divergence check in the guest pull tick now distinguishes the two via the player's
currentTime(whichhandleAudioEndedresets to 0, while a real pause leaves it mid-track). Without the discriminator the guest sat silent on every host-driven track change that arrived in the 0–2.5 s window after the guest's own track had ended. - Initial-sync and Catch Up both now wait for the audio engine to actually report playing before seeking (up to 5 s on initial-sync, 4 s on Catch Up). The previous timeout-and-fire approach would drop a seek onto a not-yet-ready engine, where it silently no-oped — guest played from 0:00 while believing they were synced. The visible symptom was "I clicked Catch Up and the song jumped 50 % forward": the second click finally caught the engine ready, so the seek that should have happened on join finally landed.
- Catch Up button no longer flickers and no longer changes the bar height. Drift is computed from a noisy signal (guest's
currentTimeupdates in coarse chunks while host's position is extrapolated linearly), so the diff swings ±5 s on a normal session even when sync is fine — and the button popping in/out shifted the whole Orbit bar up and down because it was 6 px taller than the other action buttons. Visibility is now debounced (must stay over the threshold for ≥ 3 s before the button appears) and the button matches the 26 px height of its neighbours so the bar's vertical layout is stable. - Double-clicking the inline play button on an album-track row now suggests/enqueues to the host's queue, matching the existing double-click-on-row behaviour. Previously the button stopped propagation on click, so its double-click never reached the row's orbit-aware handler — clicking it twice just bounced the "double-click to add" hint toast.
- Track preview is now hidden + blocked during an Orbit session. The preview path runs through the same Rust audio engine as the shared playback, so starting a preview as a guest would clobber the host's track in the local player. The preview button is hidden across all surfaces (album / artist / favourites / playlist / random-mix track lists) via a global
[data-orbit-active]CSS rule, andpreviewStore.startPreviewno-ops as a defensive guard for keyboard shortcuts and any programmatic callers. - Audio reliably starts on join, even after a slow first cold-start (PR #526). Two cases were leaving the guest silent on join: (a) the initial sync's 5 s ready-poll timed out (slow Navidrome warmup), the next pull tick took the cheap "track already loaded" shortcut and fired
seek+resumeon a stuck engine — both no-oped against a track that never started; (b)playTrack's optimisticisPlaying: truewrite masked a lateraudio_playrejection, so the guest tick recorded a "successful" sync but the engine had silently fallen back to paused. Both are now handled: the shortcut is gated on engine state matching the host's expected state, and a recovery check at the top of the pull tick resets the anchor whenever the engine is paused while the host is still playing the same track — the next 500 ms fast-poll fires a freshplayTrackand audio kicks in. - Catch Up button stays clickable on high-latency sessions (PR #527). On a real-world high-latency session the genuine drift fluctuates between ~1 s and ~8 s in lockstep with both sides' chunked
currentTimeupdates — the previous single-stage debounce hid the button as soon as drift briefly dipped below 3 s even though the baseline drift was still 5–8 s, so the button "appeared and vanished too fast to click". Two-stage hysteresis now: show after drift > 3 s for 3 s, hide only after drift < 1 s for 1 s. - Initial-sync seek visually sticks on join (PR #528). On join the waveform briefly showed the host's live position then snapped back to 0:00 with audio playing from the start — the post-
playTrackpoll firedapplyMirror's seek againstplayTrack's optimisticisPlaying: true, before the Tauriaudio_playhad actually produced any samples. The seek's store update landed (waveform at 70 %) but theaudio_seekdebounced behind it no-oped on the not-ready engine, and the engine's first progress events from 0:00 overwrote the optimistic position. Now the poll waits forcurrentTime > 0.1before applying the seek, andapplyMirrordefers the play-state mirror by 200 ms so the seek's invoke wins the IPC ordering race against pause/resume. - Host single-track plays no longer wipe the Orbit queue (PR #529). A
playTrack(track, [track])call from any UI that passes an explicit 1-track replacement queue (e.g. OfflineLibrary's "Play this album" on a single-track album) slipped past the orbit bulk-guard (which only fires forqueue.length > 1) and replaced the host'splayerStore.queue. Since the host's queue is the shared Orbit queue, that one click destroyed every accepted guest suggestion + every upcoming track. Now intercepted: when role is host and the incoming queue is a single track, append + jump instead of replacing. - Host pause / resume reaches guests immediately (PR #537, reported by xrexy on Discord). The host previously pushed state only on a 2.5 s timer; combined with the guest's 2.5 s poll plus network latency, a
pausecould take up to ~5 s to land — long enough for the guest to noticeably run past the host. The host now also pushes state on everyisPlayingflip, in addition to the timer. Non-flip ticks are unchanged, so baseline traffic stays the same. - Guest seekbar is read-only inside an Orbit session (PR #537, reported by xrexy on Discord). Drag / click / wheel / hover are all disabled on the waveform while you're a guest; the bar shows as dimmed with a
not-allowedcursor so the disabled state is unambiguous. Previously a guest seek would jump the local player and either snap back at the next host poll (inconsistent UX) or push the guest into a diverged state where Catch Up was the only way back. Hosts and non-orbit users see no change.
Context menu — render above the floating player bar
By @Psychotoxical, reported by Prymz, PR #522
- The main context menu wrapper carried an inline
zIndex: 999that overrode the stylesheet'sz-index: 10000, and the floating player bar sits atz-index: 1000. Right-clicking a track near the bottom of the screen with the floating bar enabled cut off the bottom of the menu (issue #521). - Inline override removed; the stylesheet rule wins so the menu always paints above the floating bar. Submenus inherit the parent menu's stacking context and follow.
Home — Because-you-listened rail compact in narrow layouts
By @Psychotoxical, PR #520
- When the rail container drops below the 2-card threshold (≈ 696 px — sidebar + queue both open, mini, etc.), the home Because-you-listened section now switches to the standard
AlbumRowlayout instead of stretching the hero-style cards to full width. The narrow path inherits the rail's existing perf tuning (artwork budget, viewport windowing, scroll paging). - Wide layouts (>= 696 px) keep the existing 3-up hero cards with the "Similar to X" pill, album metadata, and album release-type pills — full-screen view is unchanged.
- Detection runs through a single
ResizeObserveron the rail wrapper. The wide path adds zero extra renders.
Security — Tauri patch for IPC origin-confusion (GHSA-7gmj-67g7-phm9)
By @Psychotoxical, PR #509
- Bumped Tauri 2.11.0 → 2.11.1 to pick up the upstream patch for GHSA-7gmj-67g7-phm9 — older Tauri versions had an origin-confusion bug that could let a remote-origin page loaded inside the webview invoke local-only IPC commands. Severity medium. Psysonic exposes a number of file-system and credential-bearing IPC commands (downloads, Navidrome native API, audio engine), so closing the gate is worth the bump.
- Lockfile-only refresh;
Cargo.tomlwas already unlocked attauri = "2". Full Tauri family (build / codegen / macros / runtime / runtime-wry / utils) bumped together at matching patch level.
Hot cache, HTTP streaming replay, and queue source indicator
stream_completed_cacheis no longer cleared onaudio_stop, so a fully buffered ranged download is not thrown away when the queue ends; starting the same track again can reuse RAM or hot-disk data instead of running a full HTTP ranged fetch from scratch (when hot cache is enabled and promotion succeeds).- Same-track
playTrackand coldresumeafteraudio:ended(engine not in paused-loaded state) await hot-cache promote soresolvePlaybackUrlcan switch topsysonic-local://before the nextaudio_play. - Ranged HTTP sources merge format hints from the URL tail,
Content-Type,Content-Dispositionfilename, and Subsonicsong.suffix(IPCstreamFormatSuffix). A bounded Range probe runs only when hints are still missing. Genericvideo/mp4Content-Type is not treated as an audio-container hint. - SQLite analysis: skip redundant CPU seeds when waveform and loudness rows already exist; emit
analysis:waveform-updatedonly after a real DB upsert, not on cache-hit no-ops. Ranged / legacy download tasks re-check playback generation after awaits before writing the completed-stream slot. - Queue panel source icon (stream / hot cache / offline) now updates on resume, queue-undo restore, and gapless
audio:track_switched, not only whenplayTrackruns. - TypeScript / robustness: non-null
activeServerIdbindings for promote IPC;.catchon the same-track promote → play promise chain with generation guard and prefetch-reset on failure.
Sidebar — New Releases read state under storage cap
- When persisted “seen” release IDs hit the 500-id cap, fresh reads are merged at the front of the capped set so unread badges do not come back incorrectly (
mergeSeenNewReleaseIdsCap).
Windows — tray double-click
- Double-click on the tray icon opens (or focuses) the main window without a spurious context-menu interaction; tray module import cleanup.
Playback stability — preview seekbar, sleep/wake recovery, and card-hover jitter
- Preview seekbar: while Rust preview playback pauses the main sink, the main seekbar no longer extrapolates forward, and preview end no longer causes a brief forward jump before snapping back.
- Sleep/wake audio recovery: added post-sleep output reopen hooks for Windows (power notifications) and Linux (logind
PrepareForSleepwake signal), plus a guarded fallback watchdog path and richer runtime diagnostics. - False-positive mitigation: the watchdog now arms only after a long poll gap (sleep/resume-like condition) and logs arm/clear/trigger decisions, reducing unexpected stream reopens during normal playback.
- Card hover stability: removed vertical lift on album/artist/base cards to avoid pointer-edge pulsation, kept artwork zoom smooth, and dropped per-card GPU layer hints that could regress software-composited Linux paths.
Analysis queue control — prune stale backfill jobs and cap warmup window
- Added a queue-prune path for pending analysis work so stale
http_backfill/cpu_seedjobs are dropped when tracks leave the active playback queue. - Limited loudness backfill warmup to the current track plus the next 5 tracks, reducing runaway analysis scheduling from large bulk queue updates.
- Added debug counters for prune results to make queue-pressure behavior visible during diagnostics.
Sidebar — Playlists icon and hover hitbox in collapsed mode
By @Psychotoxical, PR #481
- The Playlists icon in the collapsed sidebar was off-centre and had a wider hover background than every other item, because it still rendered through the expanded-mode wrapper (with
padding-rightand aflex: 1main link to fit the expand-toggle). Collapsed mode now reuses the standard nav-link path — same hitbox, same alignment as Artists, Albums, Favorites, etc.
Tracklist — drop now-playing pulse + EQ-bar animations
By @Psychotoxical, PR #488
- The currently-playing track in any tracklist (AlbumDetail, ArtistDetail, PlaylistDetail, Favorites, RandomMix) ran an
opacitypulse on the entire row plus threetransformkeyframe EQ-bar siblings — both compositor properties, but on WebKitGTK without compositing (Linux + NVIDIA proprietary +WEBKIT_DISABLE_COMPOSITING_MODE=1) every animated row falls back to a full software repaint of the subtree per frame. On AlbumDetail the combined cost held the WebProcess at ~80 % CPU for the duration of playback; CPU dropped immediately on pause/stop. .track-row.activekeeps the accent-tinted background but no longer pulses. The "now playing" indicator becomes a single LucideAudioLinesicon (one SVG per active row instead of three animated spans). Cleanup: deadtrack-pulse+eq-bouncekeyframes and a duplicate, shadowed.eq-barblock intheme.css.
Radio — queue navigation, dedup, and similar-first variety
By @Psychotoxical, reported by netherguy4, PR #503
- Queue navigation through duplicates:
playTrackre-resolved the active slot viafindIndex(... .id === ...), which returns the first matching id. Reaching a track's second occurrence snappedqueueIndexback to the earlier slot — highlight visibly jumped and the next auto-advance played the wrong follow-up.next(),previous(), theaudio:endedrepeat-one path, queue-row click and the queue-item Play Now now pass an explicit target index throughplayTrack. - Queue duplicates:
enqueueRadiodidn't dedupe incoming tracks; thenext()top-up deduped against the live queue but trimmed the played tail down to 5 history entries, so songs heard a few advances ago could be re-added by a later Last.fm / topSongs response; and the.filter(...)pass admitted intra-batch repeats (top + similar overlap is common) because it read the dedup set before mutating it. A radio-session-scoped seen-set, reset on artist change andclearQueue, closes all three paths. - Variety: Starting Radio on a track no longer queues five top tracks of the seed artist before any similar-artist material plays. The seed path and both top-up paths lead with similar songs and only fall back to top tracks when similar comes back empty (no Last.fm / small library).
Track preview — volume slider ignored during preview
By @Psychotoxical, reported by netherguy4, PR #502
- The Rust preview sink had its volume set once at preview start and was never updated afterwards —
audio_set_volumeonly ramps the main sink. Slider drags during preview therefore had no audible effect on the preview level. - With loudness normalization on (default
-4.5 dBpre-analysis attenuation), even a 100 % slider produced1.0 × 0.596 × 0.891 ≈ 53 %at the speaker, matching the reporter's "fixed at around 50 %" observation. - New
audio_preview_set_volumecommand and aplayerStoresubscription in the frontend keep the preview sink in lock-step with the slider while a preview is in flight (settings tweaks during preview are intentionally not synced — preview windows are short).
Tray — broken navigation after restoring via desktop / start-menu shortcut
By @Psychotoxical, reported by netherguy4, PR #501
- When the main window was closed to the tray and then re-opened via the desktop / start-menu shortcut (instead of the tray icon), the window came back but the next navigation rendered a blank page. Restoring via the tray icon worked correctly.
- Root cause: closing to the tray injects a "pause rendering" snippet that sets
data-psy-native-hidden="true"on<html>and pauses every CSS animation. The tray-icon restore path injects the matching "resume rendering" snippet before showing the window — the second-launch restore path (handled by the single-instance plugin) was missing that step, so route wrappers using.animate-fade-in(animation: fadeIn … both, starts atopacity: 0) stayed frozen invisible. - Fix: mirror the tray-icon restore path and resume rendering before
show()in the single-instance callback. Both restore paths are now consistent.
Player UI — broken album-art icon when switching tracks
- Fixes issue #606: the small cover in the player bar (and other
CachedImagesurfaces) no longer flashes the browser broken-image placeholder for a split second when skipping tracks or changing the current queue item.
Album & player — split OpenSubsonic album credits and performers
- Album detail header shows multiple album artists when the server sends OpenSubsonic
albumArtistson the album or on child songs — each name links to its artist page instead of only the first id (issue #552). - Player bar, mobile now playing, and mini player copy
artiststhroughsongToTrackso multi-performer tracks get per-artist links like the album tracklist column.