Block capture-phase dragstart in the webview so stray native HTML5 drags
(e.g. SVG reorder grips) do not create a stuck translucent GTK surface on
Wayland compositors.
Harden PsyDnD lifecycle when mouseup never reaches the webview: blur,
visibilitychange, pointerup/cancel in capture, and Escape cancel the
floating label without dispatching a drop.
Add -webkit-user-drag:none on sidebar customizer grips.
Each dashboard card (Album, Top Songs, Credits, Artist, Discography,
On Tour) is now a widget the user grabs and drops anywhere — reorder
within a column or move across columns. Layout persists per-install
via the new nowPlayingLayoutStore (Zustand + localStorage, same shape
as sidebarStore with an onRehydrateStorage guard for unknown IDs).
Drag uses psyDnD (useDragSource / psy-drop event, the mouse-event
based system from DragDropContext) — HTML5 native DnD is a no-go on
WebKitGTK Linux where it always shows a forbidden cursor. The whole
card is the drag handle; the column listens via a document-level
mousemove for the drop indicator and via a global psy-drop listener
that reads the latest hovered column from a ref, so drops below the
last card still land correctly regardless of column height.
Visibility is toggled from a layout menu in the top-right of the
dashboard — two sections (visible / hidden) plus a reset-to-defaults
button. No hide buttons on the cards themselves (keeps the card UI
uncluttered). An equal-height grid (align-items: stretch + min-height
on columns) keeps the drop zone active for the full vertical span of
either column.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the flat Now Playing page with a two-column dashboard: hero
(cover, metadata, badges, Last.fm stats + love button, release-age
tagline), and a grid of themed cards (Album tracklist with sliding
window, most-played-by-artist, credits, about-the-artist with Last.fm
bio fallback, compact discography contact-sheet with expand, upcoming
tours via Bandsintown). Adds lastfmGetTrackInfo + lastfmGetArtistStats
for global listener counts + userplaycount, plus Last.fm love/unlove
wired to the new hero button. Module-level TTL caches per entity keep
same-artist track switches instant. Artist-image guard filters the
well-known Last.fm no-image placeholder so the card collapses cleanly
when no real image exists.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up on #263. Refines the cross-tab search into a proper
command-palette-ish flow and polishes the UX around it.
Results-only mode:
- When a query is active, the tab content is hidden entirely; the
results list is the only thing shown. Unified list (was: in-place
current-tab filter + "In other tabs" panel underneath), sorted with
current-tab matches first and then by score.
- Removed the DOM-based hide/show logic and its CSS helper class, the
dead searchOtherTabs i18n key, and the searchHits/otherTabHits state.
Keyboard navigation inside the search field:
- ↓/↑ moves selection through the result list (clamped, no wrap).
- Enter activates the selected item (same flow as a click — clears
query, switches tab, scrolls + opens + flashes the target).
- Mouse hover syncs with the keyboard selection so the two input modes
don't fight each other.
- Selected item gets accent-border + tinted background; scrollIntoView
with block:'nearest' keeps it visible when the list is long.
Ctrl/Cmd+F (Settings page only):
- Window keydown listener registered on mount, removed on unmount →
the shortcut is live only while Settings is rendered.
- Opens the search input, focuses + selects it even if already open.
- preventDefault blocks the native WebKit find bar.
- Placeholder now hints at the shortcut: "… (Ctrl+F)" / "… (⌘F)" on
macOS, no new i18n keys needed.
Target flash animation:
- After navigating from a result, the target SettingsSubSection gets a
1.4s accent-colored border + glow pulse so the user immediately sees
which entry the result pointed at. Reflow trick lets it retrigger
when clicking the same result twice.
Misc:
- "Next Track Buffering" → "Buffering" in all 8 locales + the section
comment in Settings.tsx. The SETTINGS_INDEX keeps "next track" as a
keyword so old search habits still find it.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cross-tab settings search:
- Static SETTINGS_INDEX covering every sub-section of every tab, with
free-form keywords per entry so "scrobble" finds Last.fm, "replay gain"
finds Playback, etc.
- matchScore() does substring-first ranking, falls back to a char-in-order
fuzzy match so typos still yield hits.
- Active tab keeps its in-place hide/show filter; matches from other tabs
render at the top as a clickable "In other tabs" list (tab badge +
sub-section title). Clicking clears the query, switches tab, and scrolls
+ opens the target sub-section.
- All 8 locales updated: new searchOtherTabs key, searchNoResults reworded
(old "try another tab" hint is now handled by the list itself).
Settings restructure polish:
- "Next Track Buffering" moved from Audio to the Storage tab — sits
between Offline Library and Downloads.
- Storage tab label renamed across 8 locales: "Storage & Downloads" →
"Offline & Cache" (equivalents per locale). nb locale was missing
tabStorage entirely; added. Tab id stays 'storage' to avoid breaking
persisted state.
- Library tab: Random Mix sub-section now opens by default (previously
both sub-sections started collapsed) and is renamed "Random Mix
Blacklist" in all 8 locales — the section is entirely a blacklist
config, the old title was misleading.
- Lyrics tab:
* Standard / YouLyPlus rows use the same toggle style as the "Show
lyrics as static text" row and stack vertically. Order swapped so
YouLyPlus is first.
* Provider list (Server / LRCLIB / Netease) moved to appear directly
under the Standard toggle as its sub-item, indented, instead of after
the static-text toggle.
* Classic / Apple Music-like rows likewise rebuilt as vertical toggle
rows.
- One pre-existing typo carried in from #261 fixed along the way: the
openAddServerInvite handler in Settings referenced the renamed tab id
'server' (pre-#259) — now 'servers'. [Note: already landed in PR #262,
keeping this commit clean of that.]
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(server-switch): keep cached playback alive across servers
Drop the clearQueue() + initializeFromServerQueue() calls that were added
alongside the header server switcher (c75297fc). clearQueue() fires
audio_stop, killing the currently-playing (cached) track the moment the
user clicks a different server. Restore the pre-c75297fc behaviour: just
swap activeServerId, let the Rust audio engine keep streaming the
already-resolved URL, and let the next user action or the app-boot hook
pick up the new server's saved queue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(settings): use renamed 'servers' tab id in paste-invite handler
Leftover from #261 landing on top of #259 — the Settings tab was renamed
from 'server' to 'servers' in #259, but the openAddServerInvite route-state
handler still used the old literal. Caused a TS error and meant pasting a
psysonic1- invite did not actually switch tabs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(share): library deep links (psysonic2) with paste and toolbar actions
Add base64url-encoded payloads for track, album, artist, and ordered queue.
Handle paste outside inputs to switch server, validate entities, navigate or
play. Reuse clipboard helper for context menu, queue panel, album and artist
headers. Tests distinguish server invite strings from library shares.
* fix(share): play partial queue when some shared tracks are missing
Skip unavailable song IDs when pasting a queue share while preserving order.
Toast when some tracks are missing; error only if none resolve. Add
openedQueuePartial and queueAllUnavailable i18n; remove queueTracksMissing.
* feat(settings): paste server invites globally and scroll to add form
Handle psysonic1- magic strings outside inputs: navigate to settings or login
with prefilled add-server fields. Decode invites embedded in surrounding text.
Scroll the add-server block into view when opening from a paste so long server
lists stay oriented.
---------
Co-authored-by: Maxim Isaev <im@friclub.ru>
* fix(navidrome-admin): force HTTP/1.1 + User-Agent + no idle pool for /auth and /api calls
Replaces plain reqwest::Client::new() in all nd_* Tauri commands
(navidrome_login, nd_list_users, nd_create_user, nd_update_user,
nd_delete_user, nd_list_libraries, nd_set_user_libraries) with a shared
nd_http_client() helper that:
- sets a real User-Agent (Psysonic/<version> (Tauri))
- pins HTTP/1.1 (avoids HTTP/2 ALPN that some reverse proxies abort on)
- disables the idle-connection pool so a second call doesn't reuse a
TCP connection that the server or proxy has already half-closed
(was producing intermittent "tls handshake eof" on external servers).
Adds nd_err() to flatten the reqwest error source chain into the
returned String, so the frontend surfaces the real cause (connection
refused, tls handshake eof, etc.) instead of reqwest's opaque
"error sending request for url (…)" wrapper.
* fix(navidrome-admin): retry + graceful error + server row polish
Rust (src-tauri/src/lib.rs):
- nd_http_client: HTTP/1.1, TLS 1.2 only, no idle pool — browser-parity
for /auth and /api so strict reverse proxies that abort reqwest's
default HTTP/2+TLS-1.3 handshake mid-flight get through.
- nd_retry: one retry after 500ms on connect/timeout errors only
(ECONNRESET, TLS handshake EOF). Aggressive retries could push the
nginx upstream probe into offline state; this is the minimal useful
amount.
- nd_err: flatten the reqwest error source chain so the UI surfaces the
real cause instead of reqwest's opaque wrapper.
User management UI:
- Sequential load (users, then libraries) instead of parallel, to avoid
racing two TLS connections on a single nginx upstream slot.
- Friendlier failure state with a one-click Retry button that re-runs
load(). Concrete Rust error is kept as muted sub-line.
- User row layout: Magic-String button sits consistently next to
last-seen + delete for every user, regardless of admin status or
library-name length. No more mid-row jitter.
Servers tab:
- "Use" button no longer redirects to Home; stays on the Servers tab so
the active-badge migration is visible.
- Drag-and-drop reorder via grip handle (psyDnD), backed by a new
setServers() action on authStore.
- Active server row now has an accent-tinted background on top of the
border — harder to miss.
New i18n keys (userMgmtLoadFriendly, userMgmtRetry) added to all 8 locales.
---------
Co-authored-by: Psychotoxical <dev@psysonic.app>
* refactor(settings): regroup tabs thematically + accordion sub-sections (progress on #257)
New tab structure: Servers (first) · Library · Audio · Lyrics · Appearance ·
Personalisation · Integrations · Input · Storage · System · Users.
Reusable SettingsSubSection (native <details>) wraps related settings per tab,
with an optional action slot for per-section reset buttons.
Moved:
- Lyrics sources + sidebar lyrics style → Lyrics
- Sidebar / artist layout / home customisers → Personalisation
- Last.fm / Discord / Bandsintown / Now-playing share → Integrations
(with an opt-in privacy banner at the top)
- Tray / minimize / Linux smooth-scroll → System (new App-Verhalten group)
- Preload mini-player / custom titlebar / show artist images → Appearance visual options
- Language picker → System
Audio, Appearance and System tabs are now fully accordion-grouped.
Library tab reduced to Random Mix + Ratings accordions.
Servers tab is its own home (server cards + AudioMuse + logout) and the default
landing tab.
Indicators in the titlebar and offline banner route to the right tab.
EN + DE translations added for the new tab labels and privacy banner; other
locales fall back to English until the full i18n pass lands.
* refactor(settings): audio/input/storage/system accordions, in-page search, Navidrome focus
Settings refactor progress — every tab is now accordion-grouped:
- Audio: Output · Hi-Res · EQ · Playback · Next-track buffer
- Input: Keybindings · Global shortcuts (reset in accordion action slot)
- Storage: Offline · Downloads
- System: Language (default open) · Behavior · Backup · Logging · About · Contributors
- Contributors: own card grid with per-contributor expand, sorted by
contribution count. Changelog accordion removed (release-notes link and
"show changelog on update" toggle now live inside About).
- About: Navidrome focus; AI credit and Special Thanks removed; new
Maintainers row (Psychotoxical + cucadmuh).
In-page search: magnifier icon next to the Settings title. Click opens a
240px search field. Filters the active tab's sub-sections by title match
(data-settings-search). Cross-tab search is tracked as a follow-up.
serverCompatible updated in all 8 locales from "Compatible with: Navidrome
Gonic Airsonic Subsonic" to a Navidrome-first wording that warns other
Subsonic-compatible servers may have reduced functionality.
Full i18n pass (tabLyrics/tabPersonalisation/tabIntegrations, aboutDesc,
privacy banner, etc. in fr/nl/zh/nb/ru/es) follows in a separate commit
after visual review.
* i18n(settings): complete localisation pass for refactored tabs
Added in all 8 locales (fr, nl, zh, nb, ru, es — en+de already in place):
- Tab labels: tabLibrary, tabServers, tabLyrics, tabPersonalisation, tabIntegrations
- inputKeybindingsTitle, searchPlaceholder, searchNoResults, aboutMaintainersLabel
- integrationsPrivacyTitle, integrationsPrivacyBody
- aboutContributorsCount_one/_other (ru also _few/_many for proper plural forms)
- aboutDesc rewritten to describe a Navidrome-focused player instead of a
generic Subsonic-compatible one.
Removed dead keys in all 8 locales:
- aboutAiCredit, aboutSpecialThanksLabel — no longer rendered
- changelog — inline changelog accordion removed
- tabServer, tabGeneral — superseded by new tab structure
---------
Co-authored-by: Psychotoxical <dev@psysonic.app>
* feat(servers): magic string invites and duplicate-aware labels
Add psysonic1- share payloads (URL, Subsonic credentials, optional server
name). Login and add-server forms decode on input and keep magic field
below standard fields. Navidrome User Management generates strings after
PUT password updates where required. Resolve duplicate display names as
username@host in chrome, settings, and login.
* feat(servers): tighten magic-string import and admin share flow
After a decoded magic string, username is read-only and the password field
shows a fixed-length mask so length is not inferred. Password reveal stays
disabled until saved-server quick connect (login) or reopening the add-
server form. Navidrome admin share UI persists the password via API before
copy and adds a plaintext-handling disclaimer. Locales updated.
* feat(settings): save new Navidrome user and copy magic string
Add a non-admin "Save and get magic string" flow: create the user, assign
libraries when needed, then encode credentials to the clipboard. Reuse
the plaintext-handling disclaimer without the password-update hint meant
for edits. Strings added for all locales.
* fix(settings): tighten Navidrome add-user validation and submit feedback
Require username, display name, and a non-empty password (trimmed) for new
users; gate Save and “save and get magic string” on explicit checks with
toast feedback. Show red borders only after a failed submit, and clear
them when the user fills the fields. When editing, keep an empty password
as “unchanged” and validate identity fields with a dedicated message.
Strings: userMgmtValidationMissingIdentity across locales.
Add OverlayScrollArea with shared thumb metrics and drag handling; size the
thumb against the rail track height so panel insets cannot push it past the
visible rail. Route scroll uses a stable viewport id; Genres restores scroll
and infinite-scroll observation against that viewport (merged with upstream
virtualized genres list and lazy routes behind Suspense).
Suppress queue resizer activation when the pointer targets the main-route
overlay scrollbar; disable the resizer while dragging the thumb and use a
grabbing cursor on the body.
Apply WebKitGTK smooth wheel to the mini webview as well as main; the mini
window reapplies the persisted setting after auth store hydration.
Per bcorporaal's request in #253: making a queue from multiple albums
required either replacing the queue (Play) or going into each album
detail page first. Three new entry points cover the common workflows:
1. Hover button on the album cover next to Play. Both buttons share
the same accent style and size so the secondary action is just as
readable as the primary — first attempt with a darker pill was hard
to see and unbalanced the hover.
2. Context-menu entry "Enqueue Album" between "Open Album" and "Go to
Artist". The i18n key already existed (was used by the album-song
sub-context); just wiring up the action.
3. Multi-select toolbar in Albums.tsx: new "Enqueue (N)" button placed
before "Add Offline" so power users selecting several albums no
longer have to right-click. Plus a context-menu entry "Enqueue N
Albums" for the same flow when a multi-album right-click happens
anywhere else.
All multi-album fetches use Promise.all(getAlbum(...)) — Navidrome
handles parallel requests instantly (per memory note from PR #246).
i18n: 4 new keys per locale (3 with pluralisation: contextMenu.enqueueAlbums,
albums.enqueueSelected, albums.enqueueQueued).
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per bcorporaal's request in #252: album-oriented listeners want a
cleaner artist page focused on the album grid, with the freedom to
reorder/hide bio, top tracks, and similar artists.
Five sections are now reorder- and toggle-able from Settings →
Personalisation → "Artist page sections": Bio, Top tracks, Similar
artists, Albums, Also featured on. The fixed header block (artist
photo, name, action buttons) stays at the top.
Implementation reuses the existing sidebar customizer pattern:
- new `src/store/artistLayoutStore.ts` modelled on `sidebarStore.ts`
with the same persist + onRehydrateStorage migration so future-added
sections don't silently disappear from existing users' configs.
- `ArtistDetail.tsx` render block refactored from a flat sequence of
conditional JSX into a `renderableSectionIds.map()` driven by the
store. The "first rendered section gets marginTop: 0" rule now
works regardless of the configured order — both hidden-by-toggle
and empty-data sections are filtered before the layout decides
what's first.
- `ArtistLayoutCustomizer` component in Settings, lifted from
`LyricsSourcesCustomizer` (drag via `useDragSource` + `psy-drop`
event, per-row toggle, reset button).
- 7 new i18n keys per locale across all 8 languages.
The store hook MUST stay above the loading / !artist early returns —
otherwise React's hook call order mismatches between renders and the
component fails silently. Lesson learned during the refactor.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported: opening the Genres page after a cold start froze the UI for
up to 30 seconds with a 500-genre Subsonic library. The page itself
was structurally trivial (one getGenres() call, sort, render) but each
card mounted a Lucide watermark SVG at size=80 — 500 complex SVGs
reconciled in one batch was the actual blocker, especially when other
cold-start useEffects were still draining the main thread.
Two changes:
- Pagination via IntersectionObserver, PAGE_SIZE 60. First paint mounts
~88% fewer cards; the observer pulls the next batch in 1500px ahead
so the user never sees the end. Same pattern Albums.tsx uses.
- useMemo for the sort. Without it, every unrelated re-render
(theme change, sidebar toggle) re-sorted all 500 entries.
Scroll-restore extended to also persist visibleCount in sessionStorage
so coming back from a GenreDetail page lands the user on a grid that
still contains the row they scrolled to.
Confirmed locally on a 500-genre library: page now renders instantly
on cold start.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Artists.tsx already paginates the DOM via PAGE_SIZE + IntersectionObserver,
so the page never holds more than 50-100 cards at a time. The actual
overhead with a 5000-artist library was the filter pipeline running on
every render — including renders triggered by selection mode toggles,
view-mode switches, image-toggle, etc. — re-walking the full artists
array twice per render (letterFilter + search filter), then re-building
the list-view groups Record from scratch.
Wrap the pipeline in useMemo:
- `filtered` recomputes only when artists / letterFilter / filter change
- `visible` recomputes only when filtered / visibleCount change
(also keeps array identity stable across unrelated re-renders)
- `groups` + `letters` recompute only when visible / viewMode change,
and skip the loop entirely in grid view (where they're unused)
No new dependency. With 5000 artists, unrelated state changes
(selection toggle, click on a card, scroll past pagination boundary)
are noticeably smoother.
Confirmed locally on a ~5000-artist library.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cold-start bundle was a single 1.88 MB / 577 KB-gzipped chunk that
parsed every page in the app before first paint, including pages a
typical session never opens.
Two-part fix:
1. Lazy-load 10 rarely-visited pages via React.lazy() — each becomes
its own chunk that only downloads when the route is hit:
Settings, Statistics, Help, WhatsNew, DeviceSync, OfflineLibrary,
LabelAlbums, AdvancedSearch, FolderBrowser, InternetRadio.
Whole <Routes> tree is wrapped in <Suspense fallback={null}> — no
visible loading state, the browser cache hits on subsequent visits.
2. Vite manualChunks splits dependencies that change rarely from app
code: react/react-dom/react-router, the @tauri-apps/* family, and
i18next. Tauri auto-updater pulls smaller deltas when only app
code changed (vendor chunks stay byte-identical and cached).
Build output:
Before: 1.88 MB / 577 KB gz monolithic
After cold start: ~450 KB gz (index 370 + react 54 + tauri 6 + i18n 20)
= 22% smaller initial download
Plus 11 lazy chunks. Notably WhatsNew's bundled CHANGELOG (215 KB /
76 KB gz) is now off the cold-start path entirely.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing module-level Map<songId, CachedLyrics> only survived
within a session. After every app restart the lyrics fetch chain
(server → lrclib → netease) ran from scratch for every track the user
played, even ones whose lyrics were already resolved 5 minutes ago
before the previous session ended.
Add an IndexedDB layer that's only consulted on RAM miss:
- L1 (RAM): unchanged, sync, hit on tab switch / track repeat
- L2 (IndexedDB, async): hit after restart, hydrates RAM
- Network: unchanged fallback chain
Key format `${serverId}:${songId}` so the same songId on two different
Subsonic servers cannot collide.
TTLs: 90 days for resolved lyrics (they rarely change once shipped),
7 days for `notFound` entries (gives the user / admin a chance to add
lyrics later without an indefinite negative cache).
YouLyPlus mode skips L2 — a persisted entry from the standard pipeline
wouldn't have word-level sync, which is the whole point of lyricsplus.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The IntersectionObserver sentinel triggered the next page only when it
came within 200px of the viewport — by the time the user had scrolled
that far, they were already at the very edge waiting for the request.
Bumping rootMargin to 1500px (~1.5 screens at typical heights) prefetches
ahead of the scroll instead of behind it, so the next batch of albums is
visibly already there when the user reaches them.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fetchTracksForSource() iterated over an artist's albums with sequential
`await getAlbum(...)` inside a for-loop. A 50-album artist sync stalled
for ~7 seconds of round-trips before any device write started.
Switch to Promise.all(albums.map(...)). Same pattern ArtistDetail.tsx
already uses for "Play All" without issues — Navidrome handles parallel
getAlbum requests fine.
Per-album errors fall back to an empty song list rather than aborting
the whole sync.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sidebar LiveSearch and mobile search overlay rendered cover thumbs via
raw <img src={buildCoverArtUrl(...)}> instead of going through the
IndexedDB-backed CachedImage path used everywhere else in the app.
buildCoverArtUrl() mints a fresh URL on every call (random salt + a new
MD5 token per Subsonic spec), so the browser cache was permanently
defeated for these thumbs — every re-render of the dropdown produced
new URLs and the browser dispatched HTTP requests for every "new"
image.
While typing in the search field this multiplied: each keystroke
triggered a re-render with fresh URLs for every visible cover, and
after the 300 ms debounce a fresh search() result triggered another
wave. Five visible album thumbs × five keystrokes ≈ 50 redundant cover
fetches in 1.5 s, manifesting as periodic typing delays.
Switch all three call sites to CachedImage with the stable
coverArtCacheKey() — same pattern CLAUDE.md explicitly recommends:
buildCoverArtUrl() generates a new ephemeral URL every call —
browser cache is useless. Use coverArtCacheKey(id, size) +
CachedImage for <img> tags.
Confirmed locally: typing in the search field is noticeably smoother.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(queue): add Now-Playing Info tab with artist bio, song credits and Bandsintown tour dates
A third tab in the right-side queue panel surfaces context for the
currently playing track:
- Artist card: image + biography from Subsonic getArtistInfo (Last.fm
"Read more on Last.fm" anchor stripped), with read-more toggle that
only appears when the bio actually overflows the 4-line clamp.
- Song info: contributor credits from OpenSubsonic contributors[]
rendered stacked (name prominent, role muted). Section is hidden
entirely on servers without contributor support, and rows that just
re-state the main artist under role "artist" are filtered out.
- On tour: optional Bandsintown integration (opt-in, off by default).
HTTP fetch + JSON parsing happen entirely on the Rust side; the
frontend wrapper deduplicates concurrent calls and caches results in
RAM for the session. Limited to 5 events with a "Show N more" toggle.
When the toggle is off, the section becomes an in-place opt-in card
with a privacy info-tooltip explaining what data is sent — same
tooltip is also exposed on the matching toggle in Settings.
Caching: artist info and song detail are memoised by stable IDs across
component remounts, so jumping between tracks of the same album/artist
does not refire the network calls.
Implementation notes:
- Bandsintown app_id "js_app_id" — arbitrary strings (e.g. "psysonic")
now return HTTP 403 from rest.bandsintown.com; js_app_id is the ID
Bandsintown's own embeddable widget uses and is broadly accepted.
- Tour items use negative left/right margins so the date badge stays
visually aligned with the section title while the hover background
extends slightly past the section edges.
- New i18n namespace nowPlayingInfo across all 8 locales (en, de, fr,
nl, zh, nb, ru, es) including pluralised "Show N more" forms.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(now-playing-info): switch nested flex-columns to block layout to stop content-dependent drift
Repeated reports of the Tour and Song-info sections rendering at
inconsistent x-positions (sometimes left of the section title,
sometimes centred, sometimes correctly aligned) — varying by artist,
sidebar width, and even whether "Show more" was clicked.
Root cause: nested flex-direction: column containers
(.np-info → .np-info-section → .np-info-tour / .np-info-credits) where
the cross-axis (horizontal) sizing on WebKitGTK occasionally inherits
the intrinsic min-content of the longest child. With one
"Naherholungsgebiet/Freizeitgelände Lago Alfredo"-style venue name
that overflows the sidebar, the parent ul gets pushed past 100% width
and the entire layout drifts. min-width: 0 on every level didn't help
because cross-axis stretch in flex-column ignores it for content-driven
overflow.
Fix: collapse all the section/list containers to display: block. Block
layout is content-agnostic — children always render at 100% parent
width, left-aligned, deterministically. Spacing between siblings now
uses the lobotomy selector (`> * + * { margin-top }`).
Only the tour item itself stays flex (badge ↔ meta horizontal layout),
and that one still has overflow: hidden + flex: 1 1 0 + min-width: 0
on the meta column to truncate venue/place text with ellipsis.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Auto mode is the new default for ReplayGain. It picks album gain when an
adjacent queue neighbour shares the same albumId (so "Play All" on an
album, or any contiguous album block, gets album gain), otherwise track
gain. Falls back to track gain when album gain is missing.
- authStore: replayGainMode widened to 'track' | 'album' | 'auto', default 'auto'
- playerStore: new resolveReplayGainDb(track, prev, next, enabled, mode) helper
replaces five inline ternaries (audio_play hot path, gapless preload,
cold-resume success + fallback, live update_replay_gain)
- Settings: third "Auto" button + contextual hint when active
- i18n: replayGainAuto + replayGainAutoDesc added to all 8 locales
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(titlebar): hide traffic-light glyphs until hover
The lucide Minus/Square/X icons at 9×9 with stroke-width 2.5 looked
chunky at all times — especially the Square for maximize. Match real
macOS behaviour: glyphs fade in only when the controls group is hovered
(or a button is keyboard-focused).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(titlebar): drop traffic-light glyphs entirely, add coloured hover glow
Following review feedback: even on-hover the lucide glyphs at 9×9
weren't pleasant. Remove them outright and signal hover with a soft
coloured glow matching each button (red/yellow/green) plus a subtle
brightness lift. Keyboard focus uses a white outer ring for accessibility.
- TitleBar.tsx: remove lucide imports + icon children, add aria-label
- layout.css: drop svg sizing rules, add per-variant box-shadow hover,
add focus-visible ring, transition box-shadow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use the main window WebView user agent as the runtime source for Rust-side HTTP clients and refresh the audio engine client when the UA changes. This keeps backend and WebView requests aligned while preserving a simple startup sync flow.
Add a System setting for Off/Normal/Debug logging, apply readable local timestamps to backend logs, and enable exporting buffered runtime logs to a file when debug mode is active.
Co-authored-by: Maxim Isaev <im@friclub.ru>
* fix(player): prevent streaming seek UI freezes and progress snapbacks
Move blocking seek work off the command path with a bounded timeout, switch fullscreen/mobile seek bars to commit-on-release, and stabilize fallback progress state so rapid early seeks on streamed tracks do not freeze the UI or jump progress to zero.
* fix(player): avoid second-seek lockups and progress flicker
Add a short lock timeout for audio seek state access to prevent UI stalls when rapid seeks overlap, and keep waveform progress stable right after seek commit to eliminate brief visual snapbacks.
* fix(player): harden seek recovery and reduce stream log noise
Keep seek fallback stable during delayed backend responses to prevent occasional resets to track start, and remove per-read stream blocking logs so normal playback diagnostics stay readable.
---------
Co-authored-by: Maxim Isaev <im@friclub.ru>
RandomMix: filters and genre mix panels collapse on mobile
- RandomAlbums / album grids: auto-fill favouring 3 columns at narrow widths
- BottomNav: More button opens a bottom sheet with remaining nav items
- MobileMoreOverlay: new sheet component with backdrop and slide-up animation
- Tracklist: mobile Title / Artist two-line stacked layout (Apple Music style)
- ArtistDetail: centred header (photo → name → buttons), icon-only buttons
except Play All, collapsible Similar Artists (5 default), 2-column album
grid, avatar glow derived from dominant cover colour
- Settings: fixed overflow in ReplayGain, Crossfade, mix rating filters,
theme scheduler, and Next Track Buffering sections
Co-authored-by: kilyabin <65072190+kilyabin@users.noreply.github.com>
Tightens the Settings panels globally: section margin-bottom 32→20px,
card padding 20→12px, divider margin 12→8px, section header h2
16→15px. Adds a subtle accent-tinted hover background on every
.settings-toggle-row (extended to card edges via negative
horizontal margin). Vertical row padding kept at 2px so the diet
isn't undone by the hover affordance.
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Maps Navidrome's lastAccessAt onto NdUser and renders it in the
User Management row as a localized relative time (Intl.RelativeTimeFormat
with the current i18n language). Tooltip carries the absolute
timestamp. Navidrome returns 0001-01-01 for never-accessed users —
detected and shown as the localized "Never" label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Navidrome library assignment to the User Management settings
panel: GET /api/library + PUT /api/user/{id}/library wired through
new Rust commands. UserForm gets a checkbox picker (hidden for
admins, who auto-receive all libraries server-side) with inline
validation. User rows compacted to a single clickable line with
hover highlight; native confirm() replaced by a portal-based
ConfirmModal (theme-aware, ESC/Enter, vertically centered).
Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructure the MiniPlayer to mirror the main window's queue panel layout:
UI changes:
- Meta block: cover + title/artist/album/year (year added to MiniTrackInfo)
- Action toolbar styled like .queue-round-btn (30px round, accent fill when
active), in order: volume, shuffle | gapless, crossfade, infinite | queue
- Volume button opens a thin 5px vertical strip slider that drops down (click
/ drag / wheel to adjust). Right-click on the volume button mutes.
- Controls + progress moved to the bottom as a true footer (margin-top: auto),
so they stay anchored even with the queue expanded
- Queue toggle moved out of the titlebar into the action bar (logically lives
with the other queue/playback toggles)
- Window size bumped to 340x260 collapsed / 340x500 expanded for the new layout
Bridge changes (miniPlayerBridge.ts):
- MiniSyncPayload extended with volume, gaplessEnabled, crossfadeEnabled,
infiniteQueueEnabled
- Bridge now subscribes to authStore in addition to playerStore so toolbar
toggle states propagate cross-window
- New events: mini:set-volume, mini:shuffle, mini:set-gapless, mini:set-crossfade,
mini:set-infinite-queue
- Bridge enforces gapless ↔ crossfade mutual exclusion (per CLAUDE.md gotcha)
so the mini doesn't need to know about both states to act
Misc:
- Belt-and-suspenders user-select: none on .mini-player-shell * to kill
Ctrl+A / mouse-drag selection that WebKit/WebView2 occasionally let through
Ports the WaveformSeek UX changes from PR #219 (original fix by @cucadmuh)
onto test/seekable-streaming:
- Split previewFraction (during drag) / commitSeek (on mouseup) so audio_seek
fires once per gesture instead of on every mousemove.
- Subscribe-effect skips external progress updates while isDragging — prevents
cursor flicker from streaming/recovery ticks fighting the dragged position.
- Clear seekTarget in seek() catch branch so a failed seek doesn't permanently
block progress updates.
Skips the byte-restart fast-path from #219: would defeat RangedHttpSource's
direct seek by restarting the stream after 450ms.
Co-Authored-By: cucadmuh <cucadmuh@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Active nav-link: raise background to 22% accent + switch text/icon to
white — lifts from 3.30:1 to >10:1 (was AA-small fail). Left purple bar
preserves the Jellyfin brand cue.
Primary buttons (btn-primary, player-btn-primary, hero-play-btn,
album-card-details-btn, queue-round-btn.active): force font-weight 700.
White on #AA5CC3 measures 4.08:1 — bold glyphs compensate visually so
button labels remain clearly legible without shifting the brand color.
Not touched: accent-as-text on bg-card (4.09:1, needs separate
--accent-text token), hover tint #be70d8, white on danger red
(cross-theme issue).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a dedicated Settings tab for managing Navidrome users (list, create,
edit, delete). Only visible to admins — gated by `/auth/login` probe on
mount. Uses Navidrome's native /api/user endpoints instead of Subsonic
(getUsers.view etc. return only the caller on Navidrome). All HTTP calls
go through new Rust commands to bypass WebView CORS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inspired by kveld9's PR #211 proposal. Adapted to stay within WebView2
and WKWebView GPU budgets — 8px blur instead of 28, theme-token colours
via color-mix instead of hardcoded white, no saturate/brightness/
contrast stack, no transitions, no !important.
Linux keeps the solid floating bar from PR #216 on every compositing
mode. Platform is detected once on mount via a data-platform attribute
on <html>.
Co-Authored-By: kveld9 <179108235+kveld9@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both AppShell and PlayerBar read floatingPlayerBar via destructured
useThemeStore() without a selector, so every unrelated theme change
(accent, font, scheduler state, …) would force a re-render. Switch to
s => s.floatingPlayerBar so these components only re-render when the
toggle actually flips.
Follow-up to PR #216.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After PR #214 replaced SpringScroller with EaseScroller, the filename
no longer matched the exported class. Rename for clarity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Synced lyrics always fill the screen due to auto-scroll, so the
existing fsa-fade-bottom overlay naturally fades content below
the active line. Plain (unsynced) lyrics start from the top and
may not fill the viewport, leaving no content for that overlay
to fade.
Add mask-image to .fsa-lyrics-container when rendering plain
lyrics (--plain modifier class). The mask operates on the visible
scroll viewport rather than the content, so the bottom fade is
always present regardless of scroll position.
The spring model (velocity × damping per rAF frame) produced an
abrupt-looking lurch on each line change — especially noticeable at
the stiffness/damping values used (0.1 / 0.78).
Replace SpringScroller with EaseScroller: a fixed-duration (650 ms)
cubic ease-out animation that starts from the current scroll position
and decelerates smoothly to the target. Restarting mid-flight is clean —
the next call captures the container's current scrollTop as the new
start point, so fast line changes never skip or jerk.
Applied to both the fullscreen Apple-style lyrics and the sidebar
LyricsPane. User-scroll cancellation (stop() + 4 s resume) is
preserved unchanged.
The psy-drop listener was attached in a useEffect keyed only on
setLyricsSources, so when the container was unmounted by the
{lyricsMode === 'standard' && …} wrapper (switching to YouLyPlus and
back), the listener stayed bound to the old DOM node and drag-to-reorder
silently did nothing.
Switch the container ref to a callback-ref stored in state, so the
effect re-runs on mount/unmount and always listens on the current DOM
node.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>