Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
32 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What is Psysonic
A desktop music player (Tauri v2 + React 18 + TypeScript) for Subsonic API-compatible servers (Navidrome, Gonic, etc.). UI is styled after the Catppuccin aesthetic with glassmorphism effects.
Commands
# Dev mode (Linux — uses X11 backend to avoid WebKit compositing issues)
npm run tauri:dev
# Equivalent to: GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 tauri dev
# Production build
npm run tauri:build
# Frontend-only dev server (no Tauri shell)
npm run dev
# Type-check + bundle frontend
npm run build
There are no test scripts. TypeScript compilation (tsc) is part of the build.
Architecture
Stack
- Frontend: React 18 + TypeScript + Vite, served inside a Tauri WebView
- Backend: Rust (Tauri v2) — handles tray icon, media key shortcuts,
exit_appcommand, and the full audio engine - State: Zustand stores (no Redux)
- Audio: Rust/rodio engine (
src-tauri/src/audio.rs) — downloads track bytes via reqwest, decodes with symphonia, plays via rodio. Replaces Howler.js. See detailed notes in the Notes section. - API: All server communication goes through
src/api/subsonic.ts— a thin wrapper around axios using Subsonic token auth (MD5 hash of password + salt) - Last.fm:
src/api/lastfm.ts— direct Last.fm API integration (scrobbling, Now Playing, love/unlove, similar artists, top stats, recent tracks). API key + secret fromVITE_LASTFM_API_KEY/VITE_LASTFM_API_SECRETenv vars (bundled at build time). - i18n: react-i18next, all translations inline in
src/i18n.ts(English, German, French, Dutch)
Key files
| File | Role |
|---|---|
src/api/subsonic.ts |
All Subsonic REST calls + buildStreamUrl / buildCoverArtUrl / buildDownloadUrl helpers. Also exports pingWithCredentials(), coverArtCacheKey(), reportNowPlaying(). getRandomSongs includes a _t timestamp param to prevent browser/axios caching. |
src/api/lastfm.ts |
Last.fm API: scrobble, updateNowPlaying, love/unlove, getTrackLoved, getSimilarArtists, getTopArtists/Albums/Tracks, getRecentTracks, getUserInfo. Auth via session key stored in authStore. |
src/utils/imageCache.ts |
IndexedDB image cache (30-day TTL) + in-memory object URL Map. getCachedUrl(fetchUrl, cacheKey) is the main entry point. Capped at 150 entries with LRU eviction + URL.revokeObjectURL. Max 5 concurrent fetches. |
src/components/CachedImage.tsx |
Drop-in <img> replacement that resolves via the image cache. Also exports useCachedUrl(fetchUrl, cacheKey) hook for CSS background-image use cases. Uses cancellation flag to prevent setState on unmounted components. |
src/components/TooltipPortal.tsx |
Global tooltip system. Listens for mouseover/mouseout on document, reads data-tooltip / data-tooltip-pos / data-tooltip-wrap attributes, renders via createPortal to document.body at z-index: 99999. Mounted once in App.tsx. Use data-tooltip instead of native title= everywhere — title= produces unstyled OS tooltips. |
src/components/CustomSelect.tsx |
Styled portal-based dropdown replacing native <select>. Accepts SelectOption[] with optional group and disabled. Positioned via useLayoutEffect, flips above trigger if near viewport bottom. Use for all select inputs. |
src/components/LastfmIcon.tsx |
Shared Last.fm SVG logo component. <LastfmIcon size={16} />. |
src/store/authStore.ts |
Multi-server support via ServerProfile[] + activeServerId. getBaseUrl() / getActiveServer() used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via localStorage (synchronous — do not change to async storage). |
src/store/playerStore.ts |
Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On playTrack: calls reportNowPlaying (Navidrome) + lastfmUpdateNowPlaying (Last.fm) independently. Persists currentTrack, queue, queueIndex, currentTime for cold-start resume. |
src-tauri/src/audio.rs |
Rust audio engine: audio_play, audio_pause, audio_resume, audio_stop, audio_seek, audio_set_volume commands. Emits audio:playing, audio:progress (100ms), audio:ended, audio:error events. MASTER_HEADROOM (-1 dB) prevents inter-sample clipping at full volume. |
src/store/themeStore.ts |
Theme selection (60 themes across 8 groups), applied as data-theme on <html> |
src/store/lyricsStore.ts |
Sidebar tab state (activeTab: 'queue' | 'lyrics'). showLyrics() / showQueue() / setTab(). Not persisted. |
src/components/LyricsPane.tsx |
Lyrics pane rendered inside QueuePanel when activeTab === 'lyrics'. Fetches from LRCLIB, parses LRC, auto-scrolls active line. Only subscribes to currentTime when synced lyrics are present. |
src/api/lrclib.ts |
Fetches lyrics from https://lrclib.net/api/get. Returns { syncedLyrics, plainLyrics }. parseLrc() parses LRC timestamps into sorted LrcLine[]. |
src/store/fontStore.ts |
Font selection (10 fonts), applied as data-font on <html>. Persisted in psysonic_font. |
src/store/keybindingsStore.ts |
Configurable keybindings — maps KeyAction to e.code strings. Persisted in psysonic_keybindings. |
src/utils/playAlbum.ts |
playAlbum(albumId) — fetches album, fades out current track (700 ms), restores volume in store only (no Rust invoke), calls playTrack. Used by AlbumCard and Hero play buttons. |
src-tauri/src/lib.rs |
Tray menu, media key global shortcuts (disabled on Linux), exit_app command |
src/App.tsx |
Root routing, RequireAuth guard, TauriEventBridge (media keys → store actions), <TooltipPortal /> mount |
src/i18n.ts |
All translations (en + de + fr + nl) inline. Language persisted in localStorage('psysonic_language'). |
src/components/Sidebar.tsx |
Sidebar nav + UpdateToast component. On mount (1.5s delay) fetches https://api.github.com/repos/Psychotoxical/psysonic/releases/latest, compares tag_name against version imported directly from package.json (build-time constant — more reliable than getVersion() from Tauri API), and shows a toast above Statistics only when a newer version exists. Silently no-ops if offline. |
src/components/AlbumHeader.tsx |
Extracted from AlbumDetail — cover art, album info, play/enqueue buttons, bio modal, download. |
src/components/AlbumTrackList.tsx |
Extracted from AlbumDetail — tracklist with star ratings, codec labels, VA artist column, context menu. |
src/components/QueuePanel.tsx |
Queue sidebar. Toolbar with round buttons (Shuffle, Save, Load, Clear, Gapless ∞, Crossfade ≋). Gapless and Crossfade are mutually exclusive — enabling one disables the other in both the toolbar and Settings. Header shows title + count + duration inline. Crossfade popover (range slider 1–10 s) anchored below the ≋ button. Tech info (codec/bitrate) as frosted-glass overlay on cover art. Items get .context-active class while their context menu is open. |
src/components/CoverLightbox.tsx |
Full-screen image lightbox. Props: { src, alt, onClose }. ESC key + overlay click to close. Used in AlbumHeader and ArtistDetail. |
packages/aur/PKGBUILD |
AUR package definition for Arch/CachyOS. Installs a wrapper script at /usr/bin/psysonic that sets GDK_BACKEND=x11, WEBKIT_DISABLE_COMPOSITING_MODE=1, WEBKIT_DISABLE_DMABUF_RENDERER=1 before launching the binary. |
Multi-server support
authStore holds a ServerProfile[] array and an activeServerId. The ServerProfile shape is:
interface ServerProfile {
id: string;
name: string;
url: string;
username: string;
password: string;
}
Use getActiveServer() to get the current server, getBaseUrl() to get its URL.
Login / connection flow
- Connection is tested with
pingWithCredentials(url, username, password)fromsrc/api/subsonic.tsbefore writing anything to the store. - Only after a successful ping:
addServer()+setActiveServer()+setLoggedIn(true). RequireAuthinApp.tsxredirects to/loginif!isLoggedIn || !activeServerId || servers.length === 0.- Do not call
addServer()before verifying the connection — this avoids a rehydration race condition with Zustand's async storage.
Auth salt security
secureRandomSalt() in subsonic.ts uses crypto.getRandomValues() (not Math.random()) for all token auth salts.
Image caching
buildCoverArtUrl() generates a new ephemeral URL on every call (new salt) — the browser cache is useless. All cover art and artist images are cached via:
coverArtCacheKey(id, size)— stable key:${serverId}:cover:${id}:${size}CachedImagecomponent oruseCachedUrlhook — resolve via IndexedDB, fall back to direct URL- Use
useCachedUrl(notCachedImage) for CSSbackground-imageproperties - Gotcha:
useCachedUrl/ hooks fromCachedImage.tsxmust be called unconditionally, before any earlyreturnin the component. Derive inputs from nullable state (e.g.album?.album.coverArt) rather than placing the hook after guard returns.
Data flow
authStore.getBaseUrl()returns the active server's URLsubsonic.tscallsuseAuthStore.getState()directly (not hooks) to build each requestplayerStore.playTrack()callsinvoke('audio_play', { url, volume, durationHint }), callsreportNowPlaying(Navidrome) +lastfmUpdateNowPlaying(Last.fm), listens foraudio:progress/audio:endedevents, triggers scrobble at 50% directly via Last.fm API, and debounces server queue sync- Tauri events (
media:play-pause,tray:play-pause, etc.) are bridged to store actions inTauriEventBridgeinsideApp.tsx - On cold start (app restart): if
currentTrackis in localStorage,resume()callsaudio_play+ seeks to savedcurrentTime
Adding a new page
- Create
src/pages/MyPage.tsx - Add a
<Route>inAppShellinsrc/App.tsx - Add a sidebar link in
src/components/Sidebar.tsx - Add i18n keys to both
enTranslationanddeTranslationinsrc/i18n.ts
Adding a new Subsonic API call
Add a function to src/api/subsonic.ts using the api<T>() helper. The helper automatically injects auth params and unwraps subsonic-response.
Themes
60 themes across 8 groups, selectable in Settings via ThemePicker. themeStore persists the choice and sets data-theme on <html>. All component CSS uses semantic tokens (--accent, --text-primary, etc.) — only the player button gradient and a few decorative elements reference --ctp-* palette vars directly, so every theme must define the full --ctp-* set.
--volume-accent overrides the volume slider colour independently of --accent (used by WnAmp for orange volume, yellow accent elsewhere).
| Theme | Group | Style | Accent |
|---|---|---|---|
poison |
Psysonic Themes | dark charcoal, phosphor green LCD glow | Green #1bd655 |
nucleo |
Psysonic Themes | warm brass/cream light | Brass #7a5218 |
psychowave |
Psysonic Themes | deep violet synthwave | Purple #a06ae0 |
vintage-tube-radio |
Psysonic Themes | warm brown, VFD orange | Orange #FF6F00 |
neon-drift |
Psysonic Themes | midnight blue, electric cyan glow | Cyan #00f2ff |
wnamp |
Mediaplayer | cool gray-blue dark, LCD glow, Courier New | Yellow #d4cc46, volume #de9b35 |
muma-jukebox |
Mediaplayer | silver/blue light | Blue #0070a0 |
winmedplayer |
Mediaplayer | cobalt blue dark | Lime #45ff00 |
p-dvd |
Mediaplayer | near-black cinematic | Cyan #00aaff |
spotless |
Mediaplayer | flat dark, Spotify-inspired | Green #1ED760 |
dzr0 |
Mediaplayer | flat light, Deezer-inspired | Purple #A238FF |
cupertino-beats |
Mediaplayer | Apple Music dark, glassmorphism | Red #fa243c |
cupertino-light |
Operating Systems | macOS light, frosted glass | Apple Blue #0071e3 |
cupertino-dark |
Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue #007aff |
aero-glass |
Operating Systems | Win7 Aero — ice-blue glass sidebar, near-black frosted taskbar player bar, Aero button gradients | Blue #1878e8 |
w98 |
Operating Systems | Windows 98 teal desktop | Navy #000080 |
luna-teal |
Operating Systems | WinXP Luna — warm tan bg, Luna blue task-pane sidebar, XP selection blue hover #316AC5, gel buttons |
Green #3c9d29 |
w11 |
Operating Systems | Windows 11 Fluent Design dark — Mica sidebar, clean neutrals, no gradients | Windows Blue #0078d4 |
gw1 |
Games | Guild Wars 1 — aged Tyrian stone, ornate gold borders, Cinzel serif track name, Searing crimson danger | Gold #c8960c |
grand-theft-audio |
Games | GTA night city | Green #57b05a |
lambda-17 |
Games | Half-Life orange alert | Amber #ff9d00 |
nightcity-2077 |
Games | Cyberpunk 2077 | Neon Yellow #FCEE0A |
tetrastack |
Games | Tetris 8-bit, grid background | Cyan #00f0f0 |
v-tactical |
Games | Battlefield | Burnt Orange #ff8a00 |
horde |
Games | WoW Horde — Durotar blood-red earth, forge-fire gold glow, iron-plate sidebar | Blood Red #cc2200 |
alliance |
Games | WoW Alliance — Stormwind deep navy, cathedral stone, paladin holy-light glow, gold sidebar trim | Royal Blue #3388cc |
blade |
Movies | deep black, blood-red | Red #b30000 |
dune |
Movies | Arrakis — warm sand main vs cool sietch-blue #0e0c1a sidebar, spice cinnamon #c8780a accent, desert horizon gradient, sand-grain scrollbar |
Spice #c8780a |
hill-valley-85 |
Movies | BTTF — DeLorean time circuit: track name red #ff2200 Courier New uppercase, artist amber, stainless-steel sidebar lines, fire+lightning radial gradients |
Orange #ff8c00 |
imperial-sith |
Movies | Star Wars dark side | Red #e60000 |
middle-earth |
Movies | LOTR — parchment bg, Bag End oak wood-grain sidebar, ring-inscription-glow 5s animation on track name, Sammath Naur player bar, seven-stop gold progress bar, Georgia serif |
Gold #d4a820 |
morpheus |
Movies | Matrix — CRT scanlines on app-shell, vertical code-column sidebar pattern, terminal buttons (monospace, no fill, green glow border-radius 0), 6px scrollbar | Phosphor Green #00ff41 |
order-of-the-phoenix |
Movies | Harry Potter | Ember Orange #e63900 |
pandora |
Movies | Avatar bioluminescent | Cyan #00f2ff |
spider-tech |
Movies | Spider-Man — CSS spider web (conic+radial) radiating from sidebar top-left, Into the Spider-Verse halftone dots on app-shell, track name red glow, artist in suit-blue #4a88ff |
Red #E62429 |
stark-hud |
Movies | Iron Man HUD | Cyan #00f2ff |
t-800 |
Movies | Terminator, Skynet blue | Cyan #00d4ff |
aqua-quartz |
Operating Systems | Mac OS X Aqua, skeuomorphic jelly buttons | Blue #3876f7 |
dos |
Operating Systems | MS-DOS — ANSI blue #0000AA bg, Courier New everywhere, inverted grey selection, blinking block cursor on track name, 16px DOS scrollbar |
Yellow #FFFF55 |
unix |
Operating Systems | Unix Shell — pure black, Courier New, $ prompt prefix + blinking _ cursor on track name, artist in ANSI blue #5588FF (ls directory color), thin 6px scrollbar |
Green #22C55E |
w3-1 |
Operating Systems | Windows 3.1, light silver/teal | Navy #000080 |
ice-and-fire |
Series | Game of Thrones — temperature duality: volcanic warm main (#100c08) vs cold Castle Black sidebar (#090c10), fire-flicker 5s irregular animation on track name, dragon gold #c8880a artist, red-to-gold content header rule, forge fire glow on player bar |
Blood Red #c41e1e |
doh-matic |
Series | The Simpsons | Blue #1F75FE |
heisenberg |
Series | Breaking Bad — periodic table grid on app-shell, hazmat tape diagonal on sidebar, crystal-pulse 3s glow animation on track name, lab fluorescent blue top border on player bar |
Crystal Blue #35d4f8 |
turtle-power |
Series | TMNT, turtle green, brick sidebar | Green #33cc33 |
insta |
Social Media | Instagram dark, pink gradient | Pink #E1306C |
readit |
Social Media | Reddit dark, orange-red | OrangeRed #FF4500 |
the-book |
Social Media | Facebook light, blue sidebar | Blue #1877F2 |
mocha |
Open Source Classics | Catppuccin dark | Mauve |
macchiato |
Open Source Classics | Catppuccin medium-dark | Mauve |
frappe |
Open Source Classics | Catppuccin medium | Mauve |
latte |
Open Source Classics | Catppuccin light | Mauve |
nord |
Open Source Classics | Polar Night dark | Frost #88c0d0 |
nord-snowstorm |
Open Source Classics | Snow Storm light | Deep-Blue #5e81ac |
nord-frost |
Open Source Classics | deep ocean blue | Frost #88c0d0 |
nord-aurora |
Open Source Classics | Polar Night + aurora | Purple #b48ead |
gruvbox-dark-hard |
Open Source Classics | Gruvbox dark hard | Orange #fe8019 |
gruvbox-dark-medium |
Open Source Classics | Gruvbox dark medium | Orange #fe8019 |
gruvbox-dark-soft |
Open Source Classics | Gruvbox dark soft | Orange #fe8019 |
gruvbox-light-hard |
Open Source Classics | Gruvbox light hard | Orange #af3a03 |
gruvbox-light-medium |
Open Source Classics | Gruvbox light medium | Orange #af3a03 |
gruvbox-light-soft |
Open Source Classics | Gruvbox light soft | Orange #af3a03 |
Light-theme gotcha: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not var(--text-primary)) so they stay readable in light themes (Latte, Nord Snowstorm).
Artists page — initial avatars
Artist images are intentionally not loaded on the Artists overview page (grid + list view) to avoid slow server disk I/O on large libraries. Instead, each artist gets a colour-coded initial avatar: first letter of the name (skipping leading punctuation/numbers), colour deterministically hashed from the name using Catppuccin palette variables. Artist images are still loaded on the individual ArtistDetail page (cached via IndexedDB). In the grid view, the initial avatar is a circle (border-radius: 50%, border: 2px solid with the accent colour) centred inside the card. Name and album count below are centre-aligned.
Artist cards
ArtistCardLocal uses the same structure as AlbumCard: no padding, full-width square cover via aspect-ratio: 1, info below. Both use flex: 0 0 clamp(140px, 15vw, 180px) inside .album-grid so they stay the same size as album cards.
NowPlayingDropdown — Live button
src/components/NowPlayingDropdown.tsx polls getNowPlaying every 10 seconds in the background. Navidrome keeps stale "now playing" entries for several minutes after playback stops. To fix this: entries belonging to the current user (ownUsername) are filtered by the local isPlaying state from playerStore — so the badge disappears instantly when the user pauses or stops, without waiting for the server to clear the entry. Clicking an entry navigates to the album page (/album/:albumId) if stream.albumId is available.
i18n
All non-English strings live exclusively in src/i18n.ts — never hardcode translated text in .tsx files. Four languages: en, de, fr, nl. Translation namespaces: sidebar, home, hero, search, nowPlaying, contextMenu, albumDetail, artistDetail, favorites, randomMix, randomAlbums, playlists, albums, artists, statistics, login, common, settings, help, queue, player.
German terminology: "Queue" is always "Warteschlange" in German — never leave "Queue" untranslated in DE strings.
Tauri capabilities
Tauri v2 capability configs live in src-tauri/capabilities/. Schema is auto-generated into src-tauri/gen/schemas/. Modify capabilities there when adding new Tauri plugins or IPC commands.
Release / CI
Releases are triggered by pushing a v* tag. The GitHub Actions workflow (.github/workflows/release.yml) builds for macOS (arm64 + x86_64), Linux (Ubuntu 24.04 → deb + rpm), and Windows.
The workflow is split into three jobs: create-release (creates the GitHub Release with changelog body from CHANGELOG.md), build-macos-windows (macOS + Windows via tauri-action), and build-linux (Ubuntu 24.04, manual, builds only deb + rpm via --bundles deb,rpm).
AppImage is no longer built. The AppImage was fundamentally incompatible with non-Ubuntu distros (Arch, Fedora) due to the bundled WebKitGTK conflicting with the system's Mesa/EGL stack.
Never force-push or move a tag after publishing. GitHub caches release tarballs — moving a tag causes the AUR and other package managers to build stale code. Bump the patch version instead.
Linux distribution channels
| Distro family | Package |
|---|---|
| Ubuntu / Debian | .deb from GitHub Releases |
| Fedora / RHEL | .rpm from GitHub Releases |
| Arch / CachyOS | AUR: yay -S psysonic or paru -S psysonic |
AUR package (packages/aur/PKGBUILD)
- Maintained at
aur.archlinux.org/packages/psysonic(account: Psychotoxical) - Builds from source using the system's own WebKitGTK — no bundled libs, no EGL issues
- Installs a wrapper script at
/usr/bin/psysonicsettingGDK_BACKEND=x11,WEBKIT_DISABLE_COMPOSITING_MODE=1,WEBKIT_DISABLE_DMABUF_RENDERER=1 - When releasing: bump
pkgverinpackages/aur/PKGBUILD, copy to~/aur-psysonic/, runmakepkg --printsrcinfo > .SRCINFO, commit and push to AUR remote
Notes
- Media key shortcuts (
MediaPlayPause, etc.) are not registered on Linux (see#[cfg(not(target_os = "linux"))]inlib.rs). Spacebar is the keyboard shortcut for play/pause instead. playerStorepersistsvolume,repeatMode,currentTrack,queue,queueIndex, andcurrentTimetolocalStoragevia Zustandpartialize. The audio engine state is runtime-only (Rust side).- Auth data is persisted via
localStorage(synchronous Zustand storage). Do not switch to@tauri-apps/plugin-storeforauthStore— async storage causes a rehydration race condition wheregetActiveServer()returnsundefinedbefore state is restored. tauri.conf.jsonCSP is set tonull— a stricter CSP breaks HTTP requests in WebKitGTK on Linux.- App logo:
public/logo.png(used in login page and elsewhere). All platform icons generated from this vianpx tauri icon public/logo.png. - Audio engine (Rust/rodio):
audio_playdownloads the full track via reqwest, decodes with symphonia/rodioDecoder, appends to aSink. A generation counter (AtomicU64) cancels in-flight downloads when the user skips. Progress is tracked via atomic sample counter (CountingSource) — no wall-clock drift.audio:endedfires after ~1 s of consecutive near-end ticks at 100 ms intervals. Tauri IPC parameter names are camelCase (durationHint, notduration_hint) — this is a hard-learned gotcha, do not revert.MASTER_HEADROOM = 0.891_254(-1 dB) is applied to all volume calculations to prevent inter-sample clipping from modern 0 dBFS masters + EQ biquad ripple. - Seek:
playerStore.seek()debounces by 100 ms, then callsinvoke('audio_seek', { seconds }). The Rust side callssink.try_seek()first; if that fails (e.g. FLAC without a SEEKTABLE), the seek silently fails — FLAC files without SEEKTABLE are not seekable.CountingSource::try_seekonly resets the counter if the inner seek actually succeeded (prevents display desync on failure). - Gapless + Crossfade mutual exclusion: Enabling one auto-disables the other, enforced in both Settings (row opacity/pointer-events + onChange) and QueuePanel toolbar (button onClick). Both features running simultaneously caused a glitch: Crossfade moved the Sink (which had Song 2 gapless-chained) to
fading_out_sink; after Song 1's fade-out, Song 2 played at full volume from the old Sink. - Cold-start resume:
resume()checksisAudioPausedflag. If true (warm resume), callsaudio_resume. If false (cold start after restart), callsaudio_playwith saved URL thenaudio_seekto savedcurrentTime. Position preference: server queue position > 0 → use server; otherwise use localStorage value. - Drag-and-drop: All drag sources use
dataTransfer.setData('text/plain', ...)— WebView2 (Windows) does not support custom MIME types likeapplication/json. Queue reordering calculates the drop target index frome.clientYat drop time (iterates[data-queue-idx]elements, picks the first whose midpoint is below the cursor).fromIdxcomes fromdataTransfer(set indragstart, always reliable).onDragEndclears refs synchronously. All drops are handled by the<aside>container — noonDropon individual queue items. - Drag-and-drop cursor (Linux): WebKitGTK does not honour
dropEffectfor cursor display — the cursor may show as forbidden or no indicator depending on the compositor (KDE Plasma vs GNOME). DnD works correctly regardless. This is a known WebKitGTK limitation, not fixable from web content. - Fullscreen Player ("Ambient Stage"): Single centered column — no tracklist. Background uses the artist's
largeImageUrlfromgetArtistInfo()(falls back to cover art). Ken Burns animation:inset: -30%, ±8% translate, 90s cycle. No color orbs (removed — too GPU-intensive). Cover has a slow breathing animation (cover-breathekeyframe). Long song titles scroll as a marquee (MarqueeTitlecomponent — measures overflow viagetBoundingClientRect+ResizeObserver, animates via CSS custom property--scroll-amount).Track.artistIdis populated fromSubsonicSong.artistId(Navidrome returns this field) across all 18 track-construction sites. - Sidebar: Fixed width via CSS
clamp(200px, 15vw, 220px)— no drag-to-resize. Collapsed state (72px) persisted inlocalStorage. Update notification uses Tauri Shell pluginopen()to launch the system browser —<a target="_blank">does not work inside a Tauri WebView. - Artist page — external links: Last.fm and Wikipedia buttons open in the system browser via
open()from@tauri-apps/plugin-shell. Button label temporarily changes to "Opened in browser" / "Im Browser geöffnet" for 2.5 s as visual confirmation. - Tracklist columns: Order is
# | Title | [Artist (VA only)] | Favorite | Rating | Duration | Format. Format column uses120px(NOTautoor1fr) —autocaused misalignment because header and track-row are independent grid containers: "FORMAT" header text is narrower than "MP3 · 320 kbps", so thefrtitle column calculated differently in header vs rows, shifting all subsequent columns.1frfixed alignment but made the format column too wide. Fixed120pxfits all codec strings (MP3/FLAC/OGG · kbps) and aligns perfectly. Total row uses explicitgrid-columnnumbers (not negative indices). - AlbumDetail: Thin orchestrator (
src/pages/AlbumDetail.tsx) — state, handlers,useCachedUrlhook, rendersAlbumHeader+AlbumTrackList+ related albums section. Logic is split into the two extracted components. - Playlists page: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover.
- Statistics page: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via
Promise.allSettled. - Context menu:
songandqueue-itemtypes both have "Go to Album" (Disc3icon, shown only whensong.albumIdexists) and "Favorite/Unfavorite" toggle. Context menu type union:'song' | 'album' | 'artist' | 'queue-item' | 'album-song'. Starred state is read fromitem.starred(set when the item was loaded) and overridden bystarredOverridesinplayerStore(updated immediately on star/unstar so the UI reflects the change without a page reload).Trackinterface includesstarred?: string— propagated viasongToTrack()and all inline track-object construction sites. - QueuePanel meta box: Shows title (no link) → artist (linked to
/artist/:id) → album (linked to/album/:id) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in--accentcolour. - Queue toolbar: 6 round buttons (
queue-round-btn,border-radius: 50%) centred in a row. Active state:background: var(--accent); color: var(--ctp-base). Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover:position: absolute; top: calc(100% + 10px); right: 0; width: 170px— right-aligned to prevent viewport overflow. Gapless and Crossfade are mutually exclusive — clicking one disables the other. - WaveformSeek hover tooltip: Hovering over the waveform canvas shows a floating time label (
.player-volume-pct) above the cursor position, reusing the same CSS class as the volume % label. Rendered in a relative<div>wrapper; positioned vialeft: ${hoverPct * 100}%. Hidden when no track is loaded or mouse has left the canvas. - Queue hover: Queue items use
.context-activeCSS class when their context menu is open, keeping the hover highlight visible. - Random Mix hover: Row uses
.context-activeCSS class when a context menu is open for that song.contextMenuSongIdstate cleared viauseEffectwhencontextMenu.isOpenbecomes false. - Favorites songs: Full tracklist layout matching AlbumDetail —
track-row-vagrid with#, Title, Artist, Duration columns and a header row. Artist name clickable → artist page. "Add all to queue" button (btn btn-surface) next to the section title sends all favorited songs to the queue. - Random Mix — Genre Filter:
excludeAudiobooks+customGenreBlacklistinauthStore(persisted). HardcodedAUDIOBOOK_GENRESlist inRandomMix.tsxandAUDIOBOOK_GENRES_DISPLAYinSettings.tsxmust be kept in sync. Filter checkssong.genre,song.title, andsong.album. Clickable genre chips in the tracklist let users add genres to the blacklist on the fly. - Random Mix — Super Genre Mix: 9 super-genres defined in
SUPER_GENRESconstant. Server genres fetched viagetGenres()on mount;availableSuperGenresfilters to those with ≥1 keyword match.loadGenreMixuses progressive rendering —setGenreMixSongsupdated after each genre request resolves. Genre list capped at 50 (randomly sampled) so total fetch stays near 50 songs — no over-fetching.genreMixCompletestate gates the "Play All" button: button staysbtn-surfacewith liven / 50counter while loading, switches tobtn-primaryonly when all songs are ready. - RandomAlbums: No auto-refresh timer — loads once on mount, manual refresh button only.
loadingRefguards against concurrent fetches. - Queue shuffle:
shuffleQueue()in playerStore keeps current track at index 0, Fisher-Yates shuffles the rest. - Tooltips: Use
data-tooltip="text"on any element — never nativetitle=.data-tooltip-pos="top|bottom|left|right"(default: top).data-tooltip-wrapfor multi-line. Rendered byTooltipPortalinApp.tsxviadocument.bodyportal. - Scrobbling: At 50% playback,
playerStorecallslastfmScrobble()directly. Navidrome is NOT used for scrobbling. BothreportNowPlaying(Navidrome) andlastfmUpdateNowPlaying(Last.fm) are called on everyplayTrack()— independently, fire-and-forget. - Last.fm API key: Stored in
.envasVITE_LASTFM_API_KEY/VITE_LASTFM_API_SECRET. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden. - NowPlayingDropdown refresh:
spinningstate is separate fromloading— button is always clickable. Spin lasts min 600 ms viasetTimeout. Background poll (loading) does not block the button. - CoverLightbox: Shared component (
src/components/CoverLightbox.tsx). Props:{ src, alt, onClose }. ESC + overlay click to close. Used inAlbumHeader(album cover) andArtistDetail(artist avatar, wrapped in.artist-detail-avatar-btn). - Home page: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses
getArtists()full list + client-side Fisher-Yates shuffle (16 random), rendered asartist-ext-linkpill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact. - CoverLightbox + EQ popup: Both use
createPortal(…, document.body)to escapebackdrop-filterCSS containing-block issues on the player bar and other ancestors. - Version: 1.16.0