github Psychotoxical/psysonic app-v1.46.0-rc.2
Psysonic v1.46.0-rc.2

pre-release5 hours ago

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 test across 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-Encoding and transparently decodes compressed responses. The JSON-heavy endpoints — Navidrome native /api, Bandsintown, Radio-Browser, Last.fm — were the gap: the WebView's axios calls 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 + brotli on reqwest) — 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

By @kveld9, PR #534

  • 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] body lines 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-change followed / 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_log command when Settings → Logging is on Debug, so power users still get the same data in psysonic-logs-*.log for 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/q never 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-albums page with full Albums-page header parity (selection mode + Enqueue / Add Offline / Download ZIPs).
  • Detection walks Navidrome's native /api/song?_sort=bit_depth&_order=DESC and 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) — m4a and wma are intentionally excluded because they can carry both lossless and lossy codecs and Navidrome's codec field isn't reliable enough to disambiguate.
  • Streaming load: albums stream into the page progressively as each internal fetch completes (onProgress callback) instead of blocking on the full loadMore.
  • 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 getSong only 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 with getSong; on non-Navidrome servers the dialog falls back to whatever the Subsonic response carried.

Discord — album cover art from your own server

By @Sayykii, PR #462

  • Discord Rich Presence can now show album artwork from your own server via the Subsonic getAlbumInfo2 endpoint (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 enqueueAt path 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

By @cucadmuh, PR #470

  • 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 (coverArt when 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

By @Sayykii, PR #471

  • 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 releaseTypes or 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) — Subsonic getArtist only 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_id filter 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 with k=composer paste to /composer/:id; the Share button on the detail page and the right-click menu both copy a composer link.
  • 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, getArtistInfo returns 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 psysonic crate 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 top psysonic crate keeps only the Tauri-shell wiring.
  • lib.rs shrank 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 in lib.rs:setup, avoiding what would have been a 32-callsite migration to State<Arc<AudioEngine>>.
  • No user-visible behaviour change. Automated parity check confirmed 121/121 Tauri commands resolve identically vs. the pre-refactor tree; cargo check --workspace and cargo clippy --workspace --all-targets are 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 and utils/orbit.ts among 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 remaining utils/ files were grouped into topic folders.
  • The monolithic i18n locale 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 itselfEnglish, 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 languageEs for the six locales (de/fr/nl/nb/ru/zh) where it was missing — the Spanish entry was previously falling back to 'Spanish' from en.ts on every non-EN/non-ES UI.

Dependencies — npm / Cargo refresh and rodio 0.22

By @cucadmuh, PR #463

  • 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

By @cucadmuh, PR #463

  • Heavier app routes are loaded lazily so the initial JS bundle stays smaller (App.tsx and related entry wiring).
  • Restored default Vite chunkSizeWarningLimit behaviour so oversized chunks are reported again during production builds (vite.config.ts).

UI — cover cache, mainstage rails, and smoother virtual lists

By @cucadmuh, PR #468

  • 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 overscan from 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

By @cucadmuh, PR #470

  • When a pixel size misses disk but another size of the same cover:id is already cached, remote getCoverArt and 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_SIZES centralises known getCoverArt widths for invalidation and sibling lookup; coverArtRegisteredSizes.test.ts (Vitest) keeps literal coverArtCacheKey(_, n) call sites in src in sync with that list.
  • downscaleCoverBlob respects AbortSignal through canvas.toBlob.
  • CachedImage: fetchQueueBias gives artist search thumbs higher network-slot priority than album thumbs when the pool is saturated; observeRootMargin defaults 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 /login is 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 title11 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

By @kveld9, PR #490

  • 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

By @cucadmuh, PR #695

  • useCachedUrl only returns a shared blob URL when it still matches the current cacheKey, 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).
  • CachedImage clears the opacity load gate in useLayoutEffect when cacheKey changes so the first paint after a swap cannot briefly show the new src at 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-level data-perf-disable-animations switch, 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 RadioEditModal with createPortal(..., document.body) (same layering approach as Search Directory). Previously the overlay lived under nested .content-body with contain: 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)

By @cucadmuh, PR #703

  • 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 used key={id}, which triggered duplicate key warnings in React devtools and made reconciliation depend on row order. Keys now combine id with 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 search3 sometimes returns extra artist rows that duplicate a real name but list 0 albums (server-side indexing noise). search() now strips artists whose albumCount is exactly 0. Rows with no albumCount field 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 call search() (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_downloads command flips a per-download flag that download_track_offline checks after acquiring its slot and on every streamed chunk, so an in-progress transfer aborts mid-file (its .part file 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 is starting / 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 (which handleAudioEnded resets 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 currentTime updates 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, and previewStore.startPreview no-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 + resume on a stuck engine — both no-oped against a track that never started; (b) playTrack's optimistic isPlaying: true write masked a later audio_play rejection, 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 fresh playTrack and 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 currentTime updates — 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-playTrack poll fired applyMirror's seek against playTrack's optimistic isPlaying: true, before the Tauri audio_play had actually produced any samples. The seek's store update landed (waveform at 70 %) but the audio_seek debounced 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 for currentTime > 0.1 before applying the seek, and applyMirror defers 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 for queue.length > 1) and replaced the host's playerStore.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 pause could 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 every isPlaying flip, 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-allowed cursor 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: 999 that overrode the stylesheet's z-index: 10000, and the floating player bar sits at z-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 AlbumRow layout 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 ResizeObserver on 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.toml was already unlocked at tauri = "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

By @cucadmuh, PR #463

  • stream_completed_cache is no longer cleared on audio_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 playTrack and cold resume after audio:ended (engine not in paused-loaded state) await hot-cache promote so resolvePlaybackUrl can switch to psysonic-local:// before the next audio_play.
  • Ranged HTTP sources merge format hints from the URL tail, Content-Type, Content-Disposition filename, and Subsonic song.suffix (IPC streamFormatSuffix). A bounded Range probe runs only when hints are still missing. Generic video/mp4 Content-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-updated only 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 when playTrack runs.
  • TypeScript / robustness: non-null activeServerId bindings for promote IPC; .catch on the same-track promote → play promise chain with generation guard and prefetch-reset on failure.

Sidebar — New Releases read state under storage cap

By @cucadmuh, PR #463

  • 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

By @cucadmuh, PR #463

  • 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

By @cucadmuh, PR #476

  • 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 PrepareForSleep wake 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

By @cucadmuh, PR #480

  • Added a queue-prune path for pending analysis work so stale http_backfill / cpu_seed jobs 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-right and a flex: 1 main 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 opacity pulse on the entire row plus three transform keyframe 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.active keeps the accent-tinted background but no longer pulses. The "now playing" indicator becomes a single Lucide AudioLines icon (one SVG per active row instead of three animated spans). Cleanup: dead track-pulse + eq-bounce keyframes and a duplicate, shadowed .eq-bar block in theme.css.

Radio — queue navigation, dedup, and similar-first variety

By @Psychotoxical, reported by netherguy4, PR #503

  • Queue navigation through duplicates: playTrack re-resolved the active slot via findIndex(... .id === ...), which returns the first matching id. Reaching a track's second occurrence snapped queueIndex back to the earlier slot — highlight visibly jumped and the next auto-advance played the wrong follow-up. next(), previous(), the audio:ended repeat-one path, queue-row click and the queue-item Play Now now pass an explicit target index through playTrack.
  • Queue duplicates: enqueueRadio didn't dedupe incoming tracks; the next() 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 and clearQueue, 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_volume only ramps the main sink. Slider drags during preview therefore had no audible effect on the preview level.
  • With loudness normalization on (default -4.5 dB pre-analysis attenuation), even a 100 % slider produced 1.0 × 0.596 × 0.891 ≈ 53 % at the speaker, matching the reporter's "fixed at around 50 %" observation.
  • New audio_preview_set_volume command and a playerStore subscription 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 at opacity: 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

By @cucadmuh, PR #695

  • Fixes issue #606: the small cover in the player bar (and other CachedImage surfaces) 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

By @cucadmuh, PR #696

  • Album detail header shows multiple album artists when the server sends OpenSubsonic albumArtists on 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 artists through songToTrack so multi-performer tracks get per-artist links like the album tracklist column.

Don't miss a new psysonic release

NewReleases is sending notifications on new releases.