diff --git a/src/components/settings/LoudnessLufsButtonGroup.tsx b/src/components/settings/LoudnessLufsButtonGroup.tsx
new file mode 100644
index 00000000..ea918ba1
--- /dev/null
+++ b/src/components/settings/LoudnessLufsButtonGroup.tsx
@@ -0,0 +1,24 @@
+import type { LoudnessLufsPreset } from '../../store/authStoreTypes';
+
+const LOUDNESS_LUFS_BUTTON_ORDER: LoudnessLufsPreset[] = [-10, -12, -14, -16];
+
+export function LoudnessLufsButtonGroup(props: {
+ value: LoudnessLufsPreset;
+ onSelect: (v: LoudnessLufsPreset) => void;
+}) {
+ return (
+
+ {LOUDNESS_LUFS_BUTTON_ORDER.map(v => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/settings/settingsTabs.ts b/src/components/settings/settingsTabs.ts
new file mode 100644
index 00000000..dc89f132
--- /dev/null
+++ b/src/components/settings/settingsTabs.ts
@@ -0,0 +1,87 @@
+export type Tab =
+ | 'library'
+ | 'servers'
+ | 'audio'
+ | 'lyrics'
+ | 'appearance'
+ | 'personalisation'
+ | 'integrations'
+ | 'input'
+ | 'storage'
+ | 'system'
+ | 'users';
+
+// Legacy Tab-IDs die via Route-State oder persisted State noch aufschlagen koennen
+// auf die neue Struktur mappen. Gibt es keinen Match, faellt die Settings-Page
+// einfach auf 'library' zurueck.
+const LEGACY_TAB_ALIAS: Record = {
+ general: 'library',
+ server: 'servers',
+};
+
+export function resolveTab(input: string | undefined | null): Tab {
+ if (!input) return 'servers';
+ const aliased = LEGACY_TAB_ALIAS[input];
+ if (aliased) return aliased;
+ const known: Tab[] = ['library', 'servers', 'audio', 'lyrics', 'appearance', 'personalisation', 'integrations', 'input', 'storage', 'system', 'users'];
+ return (known as string[]).includes(input) ? (input as Tab) : 'servers';
+}
+
+// Statischer Suchindex ueber alle Sub-Sections aller Tabs. Mitpflegen, wenn eine
+// neue SettingsSubSection hinzukommt — sonst taucht sie nicht in der Suche auf.
+export type SearchIndexEntry = { tab: Tab; titleKey: string; keywords?: string };
+
+export const SETTINGS_INDEX: SearchIndexEntry[] = [
+ { tab: 'audio', titleKey: 'settings.audioOutputDevice', keywords: 'output device speakers headphones alsa wasapi coreaudio' },
+ { tab: 'audio', titleKey: 'settings.hiResTitle', keywords: 'hi-res hires resampling bit depth sample rate dsd 24bit' },
+ { tab: 'audio', titleKey: 'settings.eqTitle', keywords: 'equalizer eq bass treble autoeq filter pre-gain' },
+ { tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade gapless replaygain replay gain volume' },
+ { tab: 'lyrics', titleKey: 'settings.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' },
+ { tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' },
+ { tab: 'integrations', titleKey: 'settings.lfmTitle', keywords: 'last.fm lastfm scrobble' },
+ { tab: 'integrations', titleKey: 'settings.discordRichPresence', keywords: 'discord rich presence rpc' },
+ { tab: 'integrations', titleKey: 'settings.enableBandsintown', keywords: 'bandsintown concerts tours events' },
+ { tab: 'integrations', titleKey: 'settings.nowPlayingEnabled', keywords: 'now playing share dropdown presence' },
+ { tab: 'personalisation',titleKey: 'settings.sidebarTitle', keywords: 'sidebar nav navigation items reorder customize' },
+ { tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' },
+ { tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'home page customize sections' },
+ { tab: 'personalisation',titleKey: 'settings.queueToolbarTitle', keywords: 'queue toolbar buttons reorder customize shuffle save load' },
+ { tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
+ { tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
+ { tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
+ { tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering preload hot cache streaming' },
+ { tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
+ { tab: 'appearance', titleKey: 'settings.theme', keywords: 'theme color palette dark light' },
+ { tab: 'appearance', titleKey: 'settings.themeSchedulerTitle', keywords: 'theme scheduler auto time dark mode sunset' },
+ { tab: 'appearance', titleKey: 'settings.visualOptionsTitle', keywords: 'visual options animations effects titlebar mini player' },
+ { tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' },
+ { tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' },
+ { tab: 'appearance', titleKey: 'settings.fsPlayerSection', keywords: 'fullscreen player mesh blob' },
+ { tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform reduced animations performance gpu fps low-end framerate cap' },
+ { tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' },
+ { tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' },
+ { tab: 'system', titleKey: 'settings.language', keywords: 'language locale translation i18n' },
+ { tab: 'system', titleKey: 'settings.behavior', keywords: 'behavior tray minimize close start smooth scroll linux' },
+ { tab: 'system', titleKey: 'settings.backupTitle', keywords: 'backup export import settings restore' },
+ { tab: 'system', titleKey: 'settings.loggingTitle', keywords: 'log logs diagnostic debug verbose' },
+ { tab: 'system', titleKey: 'settings.aboutTitle', keywords: 'about version update changelog release notes' },
+ { tab: 'system', titleKey: 'settings.aboutContributorsLabel', keywords: 'contributors credits maintainers' },
+ { tab: 'system', titleKey: 'licenses.title', keywords: 'licenses license open source attribution copyright third party dependencies oss' },
+];
+
+// Substring-first, Fuzzy-Fallback (alle Query-Zeichen in Reihenfolge im
+// Haystack). Rueckgabe 0 = kein Match. Hoeher = besser.
+export function matchScore(haystack: string, needle: string): number {
+ if (!needle) return 0;
+ const h = haystack.toLowerCase();
+ const n = needle.toLowerCase();
+ const idx = h.indexOf(n);
+ if (idx >= 0) return 1000 - Math.min(999, idx);
+ let hi = 0;
+ for (const ch of n) {
+ const j = h.indexOf(ch, hi);
+ if (j < 0) return 0;
+ hi = j + 1;
+ }
+ return Math.max(1, 100 - Math.min(99, hi - n.length));
+}
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts
new file mode 100644
index 00000000..cab5d97a
--- /dev/null
+++ b/src/config/settingsCredits.ts
@@ -0,0 +1,279 @@
+// Credits list rendered on the Settings → System tab. Update via PR when adding a contributor.
+export const CONTRIBUTORS = [
+ {
+ github: 'jiezhuo',
+ since: '1.21',
+ contributions: [
+ 'Chinese (Simplified) translation',
+ ],
+ },
+ {
+ github: 'nullobject',
+ since: '1.22.0',
+ contributions: [
+ 'Seek debounce & race condition fix (PR #7)',
+ 'Waveform seekbar stability on position update (PR #8)',
+ ],
+ },
+ {
+ github: 'trbn1',
+ since: '1.22.0',
+ contributions: [
+ 'Replay Gain metadata propagation (PR #9)',
+ 'songToTrack() — unified track construction across all sources',
+ ],
+ },
+ {
+ github: 'nisarg-78',
+ since: '1.29.0',
+ contributions: [
+ 'Click-to-seek in synced lyrics (PR #38)',
+ 'Volume scroll wheel on volume slider (PR #38)',
+ 'Lyrics line visual states: active / completed / upcoming (PR #38)',
+ 'Queue auto-scroll to keep upcoming tracks in view; fixed re-renders from currentTime in QueuePanel (PR #115)',
+ ],
+ },
+ {
+ github: 'JulianNymark',
+ since: '1.29.0',
+ contributions: [
+ 'OGG/Vorbis container support via symphonia-format-ogg (PR #42)',
+ 'Themed toast notifications for audio playback errors (PR #43)',
+ 'Human-readable audio error messages (PR #44)',
+ ],
+ },
+ {
+ github: 'zz5zz',
+ since: '1.32.0',
+ contributions: [
+ 'Norwegian (Bokmål) translation (PR #101)',
+ ],
+ },
+ {
+ github: 'cucadmuh',
+ since: '1.33.0',
+ contributions: [
+ 'Russian translation & i18n locale split (PR #106)',
+ 'Russian locale refinements using phrasing from ru2 (PR #113)',
+ 'Gapless manual skip: honor user-initiated play over pre-chained track (PR #119)',
+ 'Hot playback cache — queue prefetch (PR #123)',
+ 'Per-server music folder filter and sidebar library picker (PR #124, PR #125)',
+ 'Richer star ratings, skip threshold, and library filtering (PR #130)',
+ 'Statistics: scope album and song totals to selected music library (PR #138)',
+ 'AudioMuse-AI discovery integration for Navidrome (PR #147)',
+ 'Hot playback cache — eviction budgeting, grace period, and live Audio settings readout (PR #153)',
+ 'Folder Browser: keyboard navigation, context menus, now-playing path emphasis, and adaptive column layout (PR #158)',
+ 'Infinite queue: artist-driven candidates via Top Songs + Similar Songs with random fallback (PR #163)',
+ 'Folder Browser: per-column filter with keyboard flow and Shift+Enter queue append (PR #165)',
+ 'Keybindings: modifier + key chords for in-app shortcuts; fixed seek ±10s units (PR #167)',
+ 'Statistics: genre insights scoped to music library, cached Subsonic fetches, localized duration formatting (PR #144)',
+ 'Audio output device picker: clearer ALSA labels, duplicate disambiguation, system-default mark, live refresh (PR #173)',
+ 'Folder Browser: arrow navigation blocked when modifier keys are held (PR #174)',
+ 'Linux audio output device picker: stable watcher (disable false enumeration-miss resets), canonicalize ALSA name drift, ghost entry for unlisted device (PR #176)',
+ 'Opus audio playback via symphonia-adapter-libopus with bundled libopus (PR #183)',
+ 'CLI player controls with D-Bus forwarding, shell completions for bash/zsh, library/audio-device/instant-mix CLI commands, active server switcher in header (PR #187)',
+ 'Streaming playback stability: stream-first start, seek recovery, crossfade/gapless backup preload, hot-cache promotion (PR #200)',
+ 'ReplayGain values in Queue tech strip (PR #196)',
+ 'Playback source badge (offline / cache / stream) in Queue tech strip (PR #201)',
+ 'WebKitGTK wheel scroll mode: smooth (kinetic) default with optional linear toggle (PR #207)',
+ 'ArtistCardLocal i18n: use plural-aware artists.albumCount key instead of hardcoded German',
+ 'NixOS / flake install guide with Cachix setup (PR #209)',
+ 'Subsonic: align Rust HTTP UA with main WebView UA (PR #235)',
+ 'UI: overlay scrollbars, resizer hit-test, and Linux mini-wheel (PR #255)',
+ 'Server invites: magic-string paste, Navidrome admin share, add-user validation (PR #258)',
+ 'Library deep links (psysonic2 scheme) — paste track/album/artist/queue (PR #261)',
+ 'Linux: stop Wayland GTK drag proxy and PsyDnD ghost (PR #268)',
+ 'Sidebar: long-press drag to reorder and hide nav items (PR #269)',
+ 'Lucky Mix — instant queue from listening history, ratings, and AudioMuse similar tracks (PR #278)',
+ 'UI perf: fix spike when medulla-perch lines up with hair-fan gestures (PR #283)',
+ 'Navidrome smart playlists workflow in the Playlists page (PR #289)',
+ 'Loudness normalization — LUFS / EBU R128 analysis cache + persistent waveform store (PR #315)',
+ 'Queue resize handle: no longer blocked by main scroll hit-test (PR #324)',
+ 'LUFS loop stabilization, serialized CPU seed work, mixed mean/peak waveform bins (PR #326)',
+ 'Queue undo/redo — hotkeys, playback-aware restore, scroll restoration (PR #331)',
+ 'Lucky Mix + mix rating filter (Navidrome / OpenSubsonic) (PR #332)',
+ 'Loudness: target sync, effective pre-analysis trim, queue/settings copy (PR #333)',
+ 'CLI logs mode with tail/follow and shell completions (PR #337)',
+ 'Player: reserve preview row space in delay modal (PR #344)',
+ 'Infinite queue: respect rating filter in top-up (PR #357)',
+ 'NixOS install guide refresh for channels and CI (PR #380)',
+ 'UI refinements — sidebar discovery indicators, adaptive header, interaction polish (PR #397)',
+ 'Queue: drag rows outside to remove with trash ghost (PR #420)',
+ 'Tauri: modularize audio and lib command layers (PR #422)',
+ 'Shortcuts: action registry, dynamic CLI help, 9 new input targets + F1 help binding (PR #435)',
+ 'Environment upgrade & hot-cache playback — replay via RAM/disk on same-track and queue-end resume, playback-source icon stays correct after resume/undo/gapless, sidebar new-releases 500-id cap merge, Windows tray double-click fix, lazy-loaded routes, rodio 0.22 migration (PR #463)',
+ 'Audio: post-sleep stream recovery (Windows + Linux) with poll-gap-armed stall watchdog; preview seekbar freeze + anti-jump on preview end; remove card hover lift and per-card GPU compositing hints (PR #476)',
+ 'Analysis queue control: prune stale http-backfill / cpu-seed jobs when tracks leave the playback queue, cap loudness backfill warmup to current + next 5 tracks, plus debug counters for diagnostics (PR #480)',
+ ],
+ },
+ {
+ github: 'kilyabin',
+ since: '1.34.0',
+ contributions: [
+ 'Russian locale improvements (PR #107, PR #120)',
+ 'Auto-install script for Debian / RHEL (PR #121)',
+ 'Album cover art in Discord Rich Presence via iTunes API (PR #111)',
+ 'Tiling WM detection: hide custom TitleBar on Hyprland/Sway/i3/etc. (PR #134)',
+ 'Russian translation: lyricsServerFirst settings strings (PR #140)',
+ 'Russian translation refinements (PR #148)',
+ 'Merge Random Mix & Albums into a single Build a Mix hub (PR #155)',
+ 'Fullscreen player: software-rendering performance fixes + portrait toggle & dimming setting (PR #156)',
+ 'Fullscreen player: stop mesh blob and portrait animations in no-compositing mode; remove seekbar box-shadow repaint (PR #175)',
+ 'Apple Music-style scrolling lyrics with spring-physics scroll for fullscreen player and sidebar; per-style controls (PR #205)',
+ 'Golos Text and Unbounded fonts with Cyrillic support (PR #206)',
+ 'Fullscreen & sidebar lyrics: duration-based ease-out scroll animator replacing spring physics; bottom fade for plain lyrics (PR #214)',
+ 'Sidebar lyrics: YouLy+ source strings render in a single line (PR #215)',
+ ],
+ },
+ {
+ github: 'kveld9',
+ since: '1.34.4',
+ contributions: [
+ 'Spanish (es) translation — 964 strings (PR #159)',
+ 'Column-header sorting for albums & playlists (PR #160)',
+ 'Multi-select for albums, artists & playlists with bulk "Add to Playlist"; collapsible sidebar playlist section; infinite scroll on Artists page; "Remove from Playlist" in context menu (PR #168)',
+ '3 visual toggles: cover art background, playlist cover photo, show bitrate badge (PR #181)',
+ '8 community themes (AMOLED Black, Monochrome Dark, Amber Night, Phosphor Green, Midnight Blue, Rose Dark, Sepia Dark, Ice Blue) + waveform live theme update (PR #182)',
+ 'Favorites redesign: sortable columns, genre filter, age range filter, new columns (PR #184)',
+ 'Albums and playlist headers redesign with improved layout and theme integration (PR #186)',
+ 'Tracklist column picker overflow fix in AlbumTrackList (PR #188)',
+ 'Spotify CSV playlist import (PR #190)',
+ 'Context menu for songs in AdvancedSearch and SearchResults (PR #191)',
+ 'Tracklist column picker alignment and toggle fix across Favorites and PlaylistDetail (PR #192)',
+ 'CSV import: dynamic match threshold, cleaned title search, score display in report (PR #199)',
+ 'Discord Rich Presence: configurable text templates for details, state and album tooltip (PR #198)',
+ 'Click-to-toggle duration / remaining time in player bar with persisted preference (PR #212)',
+ 'Opt-in floating player bar with themed background, accent-colored border, rounded album art, and centered volume section (PR #216)',
+ 'Linux GPU-vendor auto-detection to configure the WebKitGTK DMA-BUF renderer (disabled on NVIDIA proprietary) (PR #217)',
+ 'Artist page: continue playback when starting top songs (PR #220)',
+ 'Floating player bar: scroll-padding fix (PR #221)',
+ 'Queue Panel — position counter, tri-state duration toggle (total/remaining/ETA), persistent Now Playing collapse, animated EQ indicator (PR #419)',
+ 'Community themes — redesign pass: removed 5 overlapping / eye-straining palettes, added 8 new dark themes (Obsidian Black, Carbon Grey, Volcanic Dark, Forest Green, Violet Haze, Copper Oxide, Sakura Night, Obsidian Gold) (PR #490)',
+ 'Customizable queue toolbar — drag-and-drop button reordering, per-button visibility toggles, optional separator, persisted across restarts (PR #534)',
+ ],
+ },
+ {
+ github: 'nisrael',
+ since: '1.34.0',
+ contributions: [
+ 'Nightfox.nvim theme group in Open Source Classics (PR #114)',
+ 'Switch reqwest to rustls-tls for cross-platform TLS (PR #112)',
+ 'ICY stream metadata & AzuraCast Now Playing support (PR #146)',
+ ],
+ },
+ {
+ github: 'peri4ko',
+ since: '1.43.0',
+ contributions: [
+ 'WebView2 idle hooks when Tauri windows are hidden — Windows GPU and compositor mitigation (PR #273)',
+ ],
+ },
+ {
+ github: 'Sayykii',
+ since: '1.46.0',
+ contributions: [
+ 'Discord Rich Presence: cover art from your own server (Subsonic getAlbumInfo2) with three-way picker — none / server / Apple Music (PR #462)',
+ 'Artist page: group albums by OpenSubsonic releaseTypes (Album / EP / Single / Compilation / Live / Soundtrack / Remix) with deterministic order and i18n section headers in all 8 locales (PR #471)',
+ ],
+ },
+ {
+ github: 'Psychotoxical',
+ since: '1.0.0',
+ contributions: [
+ 'Initial app scaffold — Tauri v2 + React + Zustand + Subsonic protocol, multi-server auth (v1.0)',
+ 'Rust/rodio audio engine replacing Howler.js (v1.2)',
+ 'Waveform seekbar, MilkDrop visualizer, EQ bars (v1.3)',
+ '10-band parametric equalizer with per-theme UI (v1.5)',
+ 'Replay Gain, Crossfade, Download Folder Modal (v1.6)',
+ 'Last.fm scrobbling, Similar Artists, Statistics page (v1.7)',
+ 'TooltipPortal + CustomSelect portal-based UI primitives (v1.7)',
+ 'Keybindings system + font picker (v1.9)',
+ 'Lyrics system with LRCLIB integration (v1.12)',
+ 'Queue management overhaul + DnD (v1.22)',
+ 'Advanced Search + Genre Mix overhaul (v1.23)',
+ 'Playlist Management — create/edit/cover upload, drag reorder (v1.24)',
+ 'Functional tray icon, Minimize to Tray, Sidebar customization (v1.25)',
+ 'Bulk Select, Song Info modal, Recently Played (v1.26)',
+ 'In-App Auto-Update + Configurable Home (v1.27)',
+ 'Infinite Queue + Start Radio + single-click play (v1.28)',
+ 'Internet Radio with fast-start, ICY metadata support (v1.29)',
+ 'Discord Rich Presence, offline bulk download, artist images, lazy loading (v1.30)',
+ 'AutoEQ integration, resizable tracklist columns (v1.31)',
+ 'Genre browser with filter + FLAC seek fix (v1.20)',
+ 'Fullscreen Player with dynamic accent color + mesh blobs (v1.34)',
+ 'Bit-perfect hi-res playback + underrun hardening (v1.34)',
+ 'Fullscreen lyrics overlay with FsArt crossfade (v1.34.1)',
+ 'Offline Mode (beta) + MPRIS seek (v1.18)',
+ 'NSIS Windows installer (v1.19)',
+ 'WCAG contrast audits across 60+ themes (v1.17-v1.34)',
+ 'Custom Linux title bar with now-playing display (v1.34)',
+ 'Device Sync — fixed cross-OS naming scheme + playlist folders (v1.40)',
+ 'macOS signing + notarization + Tauri auto-updater (v1.40)',
+ 'Mini player — floating window, custom titlebar, queue DnD, persistent geometry, keyboard shortcut, WebView2 lifecycle fix (PR #162, v1.42.x)',
+ '67 themes across 8 groups (Mediaplayer, OS, Games, Movies, Series, Social Media, OSS Classics, Psysonic)',
+ 'Admin-gated User Management tab with per-user library assignment (PR #222)',
+ 'Comprehensive mobile UI overhaul (PR #238)',
+ 'Runtime log levels and debug log export (PR #241)',
+ 'ReplayGain Auto mode — picks track vs album gain from queue context (PR #242)',
+ 'Now-Playing Info tab with artist bio, song credits, Bandsintown tour dates (PR #244)',
+ 'Performance suite — search cover cache, DeviceSync N+1, Albums prefetch, Lyrics IDB, bundle splitting, Artists memo, Genres pagination (PRs #245-#251)',
+ 'Artist page — user-configurable section visibility and order (PR #254)',
+ 'Album enqueue via cover hover, context menu, multi-select toolbar (PR #256)',
+ 'Settings refactor — thematic tab regroup, accordion sub-sections, in-page search (PR #259)',
+ 'Navidrome admin API hardening (PR #260)',
+ 'Now-Playing redesign as info dashboard + draggable widget cards (PRs #266, #267)',
+ 'Sleep timer — circular ring + in-button countdown (PR #272)',
+ 'Streaming seek UI freeze + snapback fix (PR #236)',
+ 'Windows: tighten WebView2 idle hooks (follow-up to #273) (PR #276)',
+ 'Audio: defer chained-track volume to gapless transition (PR #277)',
+ 'Mini-player: portal volume popover so it cannot get clipped (PR #279)',
+ 'Mini-player: drop saved position when its monitor is gone (PR #280)',
+ 'Toolbar: swap Gapless / Infinite Queue icons (closes #274) (PR #284)',
+ 'Linux audio: prefer PipeWire / Pulse aliases over raw ALSA default (PR #288)',
+ 'Playlists: bulk-delete button in selection-mode header (PR #290)',
+ 'Home: refresh Mainstage when active server changes (PR #291)',
+ 'Search: enqueue on live-search click + reposition context menu on right-click (PR #298)',
+ 'Tracks library hub page (closes #299) (PR #300)',
+ 'Home: Discover Songs rail in Mainstage (PR #301)',
+ 'Search: right-click context menu on artist and album rows (PR #302)',
+ 'Unified SongRow + paginated song results in search pages (PR #303)',
+ 'Orbit — Multi-User Listen-Together merged to main (PR #304)',
+ 'Genres: tag-cloud refactor — log-scaled colour-tinted pills replace icon cards (PR #311)',
+ 'imageCache: per-component object URLs to fix the blob: load flood (PR #313)',
+ 'Queue: preserve scroll context on manual click (PR #314)',
+ 'Seekbar: split waveform style into truewave (analysed) + pseudowave (deterministic) (PR #316)',
+ 'Settings: restructure Normalization section for clarity (PR #317)',
+ 'Cross-device resume: flush play-queue position on pause + all exit paths (PR #318)',
+ 'Login: language picker on the login page (PR #328)',
+ 'Playlists: confirm before re-adding songs already in the playlist (PR #329)',
+ 'Discord: debug-build logging for Rich Presence IPC path (PR #330)',
+ 'Player: persist queue panel visibility + drop dead loudness binding (PR #336)',
+ 'Playlists: suggestion-row preview UX (PR #365)',
+ 'macOS: Equalizer canvas blanking fix when laid-out dimensions are missing (PR #384)',
+ 'Themes: Kanagawa, Atom One and 1984 palettes; OSS Classics regrouped by family (PR #390)',
+ 'Rust track preview engine + player-bar preview indicator with smart stop semantics (PRs #392, #394)',
+ 'Tray: now-playing track tooltip + i18n menu labels (PR #395)',
+ 'Preview: audio start sync, ring animation, download timeout (PR #423)',
+ 'Statistics: shareable Top-Albums card export (PR #425)',
+ 'Windows: playback stutter under GPU load — MMCSS Pro Audio promotion + animation pause + reduce-animations toggle (PR #426)',
+ 'Audio: frame-align gapless-off track-separation silence (fixes mono-channel playback after natural track end) (PR #439)',
+ 'Settings: 3-state animation mode (Full / Reduced / Static) — replaces boolean reduce-animations toggle (PR #441)',
+ 'Tracks: Highly Rated rail and per-card star display, with cache layer for ndListSongs (PR #443)',
+ 'Random Mix: playlist-size picker (50/75/100/125/150) and filter-panel layout cleanup (PR #445)',
+ 'Queue: optional "Preserve Play Next order" toggle — multiple Play Next inserts queue up behind each other instead of latest-on-top (PR #464)',
+ 'Library: "favorites only" filter on Albums, Artists and Advanced Search — toolbar toggle reading star overrides live (PR #466)',
+ 'Settings: keep current active server when adding a new one — no more auto-switch interrupting playback or library context (PR #475)',
+ 'Help page: full rewrite with 45 focused entries across 10 themed sections (Getting Started / Playback & Queue / Audio Tools / Library & Discovery / Lyrics / Sharing & Social / Personalization / Power User / Offline & Sync / Integrations & Troubleshooting), in-page live search with case-insensitive substring matching and auto-expand on hits, translated to all 8 locales (PR #485)',
+ 'Library: Browse by Composer — native-API role listing for classical libraries, library-scoped queries, composer as a first-class share entity (PR #487)',
+ 'Home: "Because you listened" recommendation rail — Last.fm-anchored similar-artist surfacing with round-robin anchor rotation per server (PR #489)',
+ 'Song Info: absolute file path on Navidrome via native /api/song/{id} — Subsonic only ever returned a relative path (or none on Navidrome), the native endpoint surfaces the full server-side location (PR #504)',
+ 'Home: Lossless Albums rail + dedicated /lossless-albums page with infinite scroll and header parity (selection mode, enqueue, offline, download ZIPs), streaming load via per-fetch onProgress, sidebar entry default visible, detection via Navidrome native bit_depth-sorted song cursor with always-lossless suffix allowlist (PR #506)',
+ 'Accessibility: OpenDyslexic font option in the Settings picker — bundled locally via @fontsource/opendyslexic, asymmetric glyph shapes for easier b/d, p/q tracking, Latin-only with translated subtitle in all 8 locales calling out the dyslexia-friendly intent and the Cyrillic/CJK fallback (PR #507)',
+ ],
+ },
+] as const;
+
+export const MAINTAINERS = [
+ { github: 'Psychotoxical' },
+ { github: 'cucadmuh' },
+] as const;
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 2776daff..163359e4 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -45,11 +45,16 @@ import { AddServerForm } from '../components/settings/AddServerForm';
import { ArtistLayoutCustomizer } from '../components/settings/ArtistLayoutCustomizer';
import { BackupSection } from '../components/settings/BackupSection';
import { HomeCustomizer } from '../components/settings/HomeCustomizer';
+import { LoudnessLufsButtonGroup } from '../components/settings/LoudnessLufsButtonGroup';
import { LyricsSourcesCustomizer } from '../components/settings/LyricsSourcesCustomizer';
import { QueueToolbarCustomizer } from '../components/settings/QueueToolbarCustomizer';
import { ServerGripHandle } from '../components/settings/ServerGripHandle';
+import { SETTINGS_INDEX, type Tab, matchScore, resolveTab } from '../components/settings/settingsTabs';
import { SidebarCustomizer } from '../components/settings/SidebarCustomizer';
import { UserManagementSection } from '../components/settings/UserManagementSection';
+import { CONTRIBUTORS, MAINTAINERS } from '../config/settingsCredits';
+import { buildAudioDeviceSelectOptions, formatAudioDeviceLabel, sortAudioDeviceIds } from '../utils/audioDeviceLabels';
+import { formatBytes, snapHotCacheMb } from '../utils/formatBytes';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import { ndLogin } from '../api/navidromeAdmin';
import { switchActiveServer } from '../utils/switchActiveServer';
@@ -65,526 +70,6 @@ const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspie
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
-const LOUDNESS_LUFS_BUTTON_ORDER: LoudnessLufsPreset[] = [-10, -12, -14, -16];
-
-function LoudnessLufsButtonGroup(props: {
- value: LoudnessLufsPreset;
- onSelect: (v: LoudnessLufsPreset) => void;
-}) {
- return (
-
- {LOUDNESS_LUFS_BUTTON_ORDER.map(v => (
-
- ))}
-
- );
-}
-
-const CONTRIBUTORS = [
- {
- github: 'jiezhuo',
- since: '1.21',
- contributions: [
- 'Chinese (Simplified) translation',
- ],
- },
- {
- github: 'nullobject',
- since: '1.22.0',
- contributions: [
- 'Seek debounce & race condition fix (PR #7)',
- 'Waveform seekbar stability on position update (PR #8)',
- ],
- },
- {
- github: 'trbn1',
- since: '1.22.0',
- contributions: [
- 'Replay Gain metadata propagation (PR #9)',
- 'songToTrack() — unified track construction across all sources',
- ],
- },
- {
- github: 'nisarg-78',
- since: '1.29.0',
- contributions: [
- 'Click-to-seek in synced lyrics (PR #38)',
- 'Volume scroll wheel on volume slider (PR #38)',
- 'Lyrics line visual states: active / completed / upcoming (PR #38)',
- 'Queue auto-scroll to keep upcoming tracks in view; fixed re-renders from currentTime in QueuePanel (PR #115)',
- ],
- },
- {
- github: 'JulianNymark',
- since: '1.29.0',
- contributions: [
- 'OGG/Vorbis container support via symphonia-format-ogg (PR #42)',
- 'Themed toast notifications for audio playback errors (PR #43)',
- 'Human-readable audio error messages (PR #44)',
- ],
- },
- {
- github: 'zz5zz',
- since: '1.32.0',
- contributions: [
- 'Norwegian (Bokmål) translation (PR #101)',
- ],
- },
- {
- github: 'cucadmuh',
- since: '1.33.0',
- contributions: [
- 'Russian translation & i18n locale split (PR #106)',
- 'Russian locale refinements using phrasing from ru2 (PR #113)',
- 'Gapless manual skip: honor user-initiated play over pre-chained track (PR #119)',
- 'Hot playback cache — queue prefetch (PR #123)',
- 'Per-server music folder filter and sidebar library picker (PR #124, PR #125)',
- 'Richer star ratings, skip threshold, and library filtering (PR #130)',
- 'Statistics: scope album and song totals to selected music library (PR #138)',
- 'AudioMuse-AI discovery integration for Navidrome (PR #147)',
- 'Hot playback cache — eviction budgeting, grace period, and live Audio settings readout (PR #153)',
- 'Folder Browser: keyboard navigation, context menus, now-playing path emphasis, and adaptive column layout (PR #158)',
- 'Infinite queue: artist-driven candidates via Top Songs + Similar Songs with random fallback (PR #163)',
- 'Folder Browser: per-column filter with keyboard flow and Shift+Enter queue append (PR #165)',
- 'Keybindings: modifier + key chords for in-app shortcuts; fixed seek ±10s units (PR #167)',
- 'Statistics: genre insights scoped to music library, cached Subsonic fetches, localized duration formatting (PR #144)',
- 'Audio output device picker: clearer ALSA labels, duplicate disambiguation, system-default mark, live refresh (PR #173)',
- 'Folder Browser: arrow navigation blocked when modifier keys are held (PR #174)',
- 'Linux audio output device picker: stable watcher (disable false enumeration-miss resets), canonicalize ALSA name drift, ghost entry for unlisted device (PR #176)',
- 'Opus audio playback via symphonia-adapter-libopus with bundled libopus (PR #183)',
- 'CLI player controls with D-Bus forwarding, shell completions for bash/zsh, library/audio-device/instant-mix CLI commands, active server switcher in header (PR #187)',
- 'Streaming playback stability: stream-first start, seek recovery, crossfade/gapless backup preload, hot-cache promotion (PR #200)',
- 'ReplayGain values in Queue tech strip (PR #196)',
- 'Playback source badge (offline / cache / stream) in Queue tech strip (PR #201)',
- 'WebKitGTK wheel scroll mode: smooth (kinetic) default with optional linear toggle (PR #207)',
- 'ArtistCardLocal i18n: use plural-aware artists.albumCount key instead of hardcoded German',
- 'NixOS / flake install guide with Cachix setup (PR #209)',
- 'Subsonic: align Rust HTTP UA with main WebView UA (PR #235)',
- 'UI: overlay scrollbars, resizer hit-test, and Linux mini-wheel (PR #255)',
- 'Server invites: magic-string paste, Navidrome admin share, add-user validation (PR #258)',
- 'Library deep links (psysonic2 scheme) — paste track/album/artist/queue (PR #261)',
- 'Linux: stop Wayland GTK drag proxy and PsyDnD ghost (PR #268)',
- 'Sidebar: long-press drag to reorder and hide nav items (PR #269)',
- 'Lucky Mix — instant queue from listening history, ratings, and AudioMuse similar tracks (PR #278)',
- 'UI perf: fix spike when medulla-perch lines up with hair-fan gestures (PR #283)',
- 'Navidrome smart playlists workflow in the Playlists page (PR #289)',
- 'Loudness normalization — LUFS / EBU R128 analysis cache + persistent waveform store (PR #315)',
- 'Queue resize handle: no longer blocked by main scroll hit-test (PR #324)',
- 'LUFS loop stabilization, serialized CPU seed work, mixed mean/peak waveform bins (PR #326)',
- 'Queue undo/redo — hotkeys, playback-aware restore, scroll restoration (PR #331)',
- 'Lucky Mix + mix rating filter (Navidrome / OpenSubsonic) (PR #332)',
- 'Loudness: target sync, effective pre-analysis trim, queue/settings copy (PR #333)',
- 'CLI logs mode with tail/follow and shell completions (PR #337)',
- 'Player: reserve preview row space in delay modal (PR #344)',
- 'Infinite queue: respect rating filter in top-up (PR #357)',
- 'NixOS install guide refresh for channels and CI (PR #380)',
- 'UI refinements — sidebar discovery indicators, adaptive header, interaction polish (PR #397)',
- 'Queue: drag rows outside to remove with trash ghost (PR #420)',
- 'Tauri: modularize audio and lib command layers (PR #422)',
- 'Shortcuts: action registry, dynamic CLI help, 9 new input targets + F1 help binding (PR #435)',
- 'Environment upgrade & hot-cache playback — replay via RAM/disk on same-track and queue-end resume, playback-source icon stays correct after resume/undo/gapless, sidebar new-releases 500-id cap merge, Windows tray double-click fix, lazy-loaded routes, rodio 0.22 migration (PR #463)',
- 'Audio: post-sleep stream recovery (Windows + Linux) with poll-gap-armed stall watchdog; preview seekbar freeze + anti-jump on preview end; remove card hover lift and per-card GPU compositing hints (PR #476)',
- 'Analysis queue control: prune stale http-backfill / cpu-seed jobs when tracks leave the playback queue, cap loudness backfill warmup to current + next 5 tracks, plus debug counters for diagnostics (PR #480)',
- ],
- },
- {
- github: 'kilyabin',
- since: '1.34.0',
- contributions: [
- 'Russian locale improvements (PR #107, PR #120)',
- 'Auto-install script for Debian / RHEL (PR #121)',
- 'Album cover art in Discord Rich Presence via iTunes API (PR #111)',
- 'Tiling WM detection: hide custom TitleBar on Hyprland/Sway/i3/etc. (PR #134)',
- 'Russian translation: lyricsServerFirst settings strings (PR #140)',
- 'Russian translation refinements (PR #148)',
- 'Merge Random Mix & Albums into a single Build a Mix hub (PR #155)',
- 'Fullscreen player: software-rendering performance fixes + portrait toggle & dimming setting (PR #156)',
- 'Fullscreen player: stop mesh blob and portrait animations in no-compositing mode; remove seekbar box-shadow repaint (PR #175)',
- 'Apple Music-style scrolling lyrics with spring-physics scroll for fullscreen player and sidebar; per-style controls (PR #205)',
- 'Golos Text and Unbounded fonts with Cyrillic support (PR #206)',
- 'Fullscreen & sidebar lyrics: duration-based ease-out scroll animator replacing spring physics; bottom fade for plain lyrics (PR #214)',
- 'Sidebar lyrics: YouLy+ source strings render in a single line (PR #215)',
- ],
- },
- {
- github: 'kveld9',
- since: '1.34.4',
- contributions: [
- 'Spanish (es) translation — 964 strings (PR #159)',
- 'Column-header sorting for albums & playlists (PR #160)',
- 'Multi-select for albums, artists & playlists with bulk "Add to Playlist"; collapsible sidebar playlist section; infinite scroll on Artists page; "Remove from Playlist" in context menu (PR #168)',
- '3 visual toggles: cover art background, playlist cover photo, show bitrate badge (PR #181)',
- '8 community themes (AMOLED Black, Monochrome Dark, Amber Night, Phosphor Green, Midnight Blue, Rose Dark, Sepia Dark, Ice Blue) + waveform live theme update (PR #182)',
- 'Favorites redesign: sortable columns, genre filter, age range filter, new columns (PR #184)',
- 'Albums and playlist headers redesign with improved layout and theme integration (PR #186)',
- 'Tracklist column picker overflow fix in AlbumTrackList (PR #188)',
- 'Spotify CSV playlist import (PR #190)',
- 'Context menu for songs in AdvancedSearch and SearchResults (PR #191)',
- 'Tracklist column picker alignment and toggle fix across Favorites and PlaylistDetail (PR #192)',
- 'CSV import: dynamic match threshold, cleaned title search, score display in report (PR #199)',
- 'Discord Rich Presence: configurable text templates for details, state and album tooltip (PR #198)',
- 'Click-to-toggle duration / remaining time in player bar with persisted preference (PR #212)',
- 'Opt-in floating player bar with themed background, accent-colored border, rounded album art, and centered volume section (PR #216)',
- 'Linux GPU-vendor auto-detection to configure the WebKitGTK DMA-BUF renderer (disabled on NVIDIA proprietary) (PR #217)',
- 'Artist page: continue playback when starting top songs (PR #220)',
- 'Floating player bar: scroll-padding fix (PR #221)',
- 'Queue Panel — position counter, tri-state duration toggle (total/remaining/ETA), persistent Now Playing collapse, animated EQ indicator (PR #419)',
- 'Community themes — redesign pass: removed 5 overlapping / eye-straining palettes, added 8 new dark themes (Obsidian Black, Carbon Grey, Volcanic Dark, Forest Green, Violet Haze, Copper Oxide, Sakura Night, Obsidian Gold) (PR #490)',
- 'Customizable queue toolbar — drag-and-drop button reordering, per-button visibility toggles, optional separator, persisted across restarts (PR #534)',
- ],
- },
- {
- github: 'nisrael',
- since: '1.34.0',
- contributions: [
- 'Nightfox.nvim theme group in Open Source Classics (PR #114)',
- 'Switch reqwest to rustls-tls for cross-platform TLS (PR #112)',
- 'ICY stream metadata & AzuraCast Now Playing support (PR #146)',
- ],
- },
- {
- github: 'peri4ko',
- since: '1.43.0',
- contributions: [
- 'WebView2 idle hooks when Tauri windows are hidden — Windows GPU and compositor mitigation (PR #273)',
- ],
- },
- {
- github: 'Sayykii',
- since: '1.46.0',
- contributions: [
- 'Discord Rich Presence: cover art from your own server (Subsonic getAlbumInfo2) with three-way picker — none / server / Apple Music (PR #462)',
- 'Artist page: group albums by OpenSubsonic releaseTypes (Album / EP / Single / Compilation / Live / Soundtrack / Remix) with deterministic order and i18n section headers in all 8 locales (PR #471)',
- ],
- },
- {
- github: 'Psychotoxical',
- since: '1.0.0',
- contributions: [
- 'Initial app scaffold — Tauri v2 + React + Zustand + Subsonic protocol, multi-server auth (v1.0)',
- 'Rust/rodio audio engine replacing Howler.js (v1.2)',
- 'Waveform seekbar, MilkDrop visualizer, EQ bars (v1.3)',
- '10-band parametric equalizer with per-theme UI (v1.5)',
- 'Replay Gain, Crossfade, Download Folder Modal (v1.6)',
- 'Last.fm scrobbling, Similar Artists, Statistics page (v1.7)',
- 'TooltipPortal + CustomSelect portal-based UI primitives (v1.7)',
- 'Keybindings system + font picker (v1.9)',
- 'Lyrics system with LRCLIB integration (v1.12)',
- 'Queue management overhaul + DnD (v1.22)',
- 'Advanced Search + Genre Mix overhaul (v1.23)',
- 'Playlist Management — create/edit/cover upload, drag reorder (v1.24)',
- 'Functional tray icon, Minimize to Tray, Sidebar customization (v1.25)',
- 'Bulk Select, Song Info modal, Recently Played (v1.26)',
- 'In-App Auto-Update + Configurable Home (v1.27)',
- 'Infinite Queue + Start Radio + single-click play (v1.28)',
- 'Internet Radio with fast-start, ICY metadata support (v1.29)',
- 'Discord Rich Presence, offline bulk download, artist images, lazy loading (v1.30)',
- 'AutoEQ integration, resizable tracklist columns (v1.31)',
- 'Genre browser with filter + FLAC seek fix (v1.20)',
- 'Fullscreen Player with dynamic accent color + mesh blobs (v1.34)',
- 'Bit-perfect hi-res playback + underrun hardening (v1.34)',
- 'Fullscreen lyrics overlay with FsArt crossfade (v1.34.1)',
- 'Offline Mode (beta) + MPRIS seek (v1.18)',
- 'NSIS Windows installer (v1.19)',
- 'WCAG contrast audits across 60+ themes (v1.17-v1.34)',
- 'Custom Linux title bar with now-playing display (v1.34)',
- 'Device Sync — fixed cross-OS naming scheme + playlist folders (v1.40)',
- 'macOS signing + notarization + Tauri auto-updater (v1.40)',
- 'Mini player — floating window, custom titlebar, queue DnD, persistent geometry, keyboard shortcut, WebView2 lifecycle fix (PR #162, v1.42.x)',
- '67 themes across 8 groups (Mediaplayer, OS, Games, Movies, Series, Social Media, OSS Classics, Psysonic)',
- 'Admin-gated User Management tab with per-user library assignment (PR #222)',
- 'Comprehensive mobile UI overhaul (PR #238)',
- 'Runtime log levels and debug log export (PR #241)',
- 'ReplayGain Auto mode — picks track vs album gain from queue context (PR #242)',
- 'Now-Playing Info tab with artist bio, song credits, Bandsintown tour dates (PR #244)',
- 'Performance suite — search cover cache, DeviceSync N+1, Albums prefetch, Lyrics IDB, bundle splitting, Artists memo, Genres pagination (PRs #245-#251)',
- 'Artist page — user-configurable section visibility and order (PR #254)',
- 'Album enqueue via cover hover, context menu, multi-select toolbar (PR #256)',
- 'Settings refactor — thematic tab regroup, accordion sub-sections, in-page search (PR #259)',
- 'Navidrome admin API hardening (PR #260)',
- 'Now-Playing redesign as info dashboard + draggable widget cards (PRs #266, #267)',
- 'Sleep timer — circular ring + in-button countdown (PR #272)',
- 'Streaming seek UI freeze + snapback fix (PR #236)',
- 'Windows: tighten WebView2 idle hooks (follow-up to #273) (PR #276)',
- 'Audio: defer chained-track volume to gapless transition (PR #277)',
- 'Mini-player: portal volume popover so it cannot get clipped (PR #279)',
- 'Mini-player: drop saved position when its monitor is gone (PR #280)',
- 'Toolbar: swap Gapless / Infinite Queue icons (closes #274) (PR #284)',
- 'Linux audio: prefer PipeWire / Pulse aliases over raw ALSA default (PR #288)',
- 'Playlists: bulk-delete button in selection-mode header (PR #290)',
- 'Home: refresh Mainstage when active server changes (PR #291)',
- 'Search: enqueue on live-search click + reposition context menu on right-click (PR #298)',
- 'Tracks library hub page (closes #299) (PR #300)',
- 'Home: Discover Songs rail in Mainstage (PR #301)',
- 'Search: right-click context menu on artist and album rows (PR #302)',
- 'Unified SongRow + paginated song results in search pages (PR #303)',
- 'Orbit — Multi-User Listen-Together merged to main (PR #304)',
- 'Genres: tag-cloud refactor — log-scaled colour-tinted pills replace icon cards (PR #311)',
- 'imageCache: per-component object URLs to fix the blob: load flood (PR #313)',
- 'Queue: preserve scroll context on manual click (PR #314)',
- 'Seekbar: split waveform style into truewave (analysed) + pseudowave (deterministic) (PR #316)',
- 'Settings: restructure Normalization section for clarity (PR #317)',
- 'Cross-device resume: flush play-queue position on pause + all exit paths (PR #318)',
- 'Login: language picker on the login page (PR #328)',
- 'Playlists: confirm before re-adding songs already in the playlist (PR #329)',
- 'Discord: debug-build logging for Rich Presence IPC path (PR #330)',
- 'Player: persist queue panel visibility + drop dead loudness binding (PR #336)',
- 'Playlists: suggestion-row preview UX (PR #365)',
- 'macOS: Equalizer canvas blanking fix when laid-out dimensions are missing (PR #384)',
- 'Themes: Kanagawa, Atom One and 1984 palettes; OSS Classics regrouped by family (PR #390)',
- 'Rust track preview engine + player-bar preview indicator with smart stop semantics (PRs #392, #394)',
- 'Tray: now-playing track tooltip + i18n menu labels (PR #395)',
- 'Preview: audio start sync, ring animation, download timeout (PR #423)',
- 'Statistics: shareable Top-Albums card export (PR #425)',
- 'Windows: playback stutter under GPU load — MMCSS Pro Audio promotion + animation pause + reduce-animations toggle (PR #426)',
- 'Audio: frame-align gapless-off track-separation silence (fixes mono-channel playback after natural track end) (PR #439)',
- 'Settings: 3-state animation mode (Full / Reduced / Static) — replaces boolean reduce-animations toggle (PR #441)',
- 'Tracks: Highly Rated rail and per-card star display, with cache layer for ndListSongs (PR #443)',
- 'Random Mix: playlist-size picker (50/75/100/125/150) and filter-panel layout cleanup (PR #445)',
- 'Queue: optional "Preserve Play Next order" toggle — multiple Play Next inserts queue up behind each other instead of latest-on-top (PR #464)',
- 'Library: "favorites only" filter on Albums, Artists and Advanced Search — toolbar toggle reading star overrides live (PR #466)',
- 'Settings: keep current active server when adding a new one — no more auto-switch interrupting playback or library context (PR #475)',
- 'Help page: full rewrite with 45 focused entries across 10 themed sections (Getting Started / Playback & Queue / Audio Tools / Library & Discovery / Lyrics / Sharing & Social / Personalization / Power User / Offline & Sync / Integrations & Troubleshooting), in-page live search with case-insensitive substring matching and auto-expand on hits, translated to all 8 locales (PR #485)',
- 'Library: Browse by Composer — native-API role listing for classical libraries, library-scoped queries, composer as a first-class share entity (PR #487)',
- 'Home: "Because you listened" recommendation rail — Last.fm-anchored similar-artist surfacing with round-robin anchor rotation per server (PR #489)',
- 'Song Info: absolute file path on Navidrome via native /api/song/{id} — Subsonic only ever returned a relative path (or none on Navidrome), the native endpoint surfaces the full server-side location (PR #504)',
- 'Home: Lossless Albums rail + dedicated /lossless-albums page with infinite scroll and header parity (selection mode, enqueue, offline, download ZIPs), streaming load via per-fetch onProgress, sidebar entry default visible, detection via Navidrome native bit_depth-sorted song cursor with always-lossless suffix allowlist (PR #506)',
- 'Accessibility: OpenDyslexic font option in the Settings picker — bundled locally via @fontsource/opendyslexic, asymmetric glyph shapes for easier b/d, p/q tracking, Latin-only with translated subtitle in all 8 locales calling out the dyslexia-friendly intent and the Cyrillic/CJK fallback (PR #507)',
- ],
- },
-] as const;
-
-const MAINTAINERS = [
- { github: 'Psychotoxical' },
- { github: 'cucadmuh' },
-] as const;
-
-type Tab =
- | 'library'
- | 'servers'
- | 'audio'
- | 'lyrics'
- | 'appearance'
- | 'personalisation'
- | 'integrations'
- | 'input'
- | 'storage'
- | 'system'
- | 'users';
-
-// Legacy Tab-IDs die via Route-State oder persisted State noch aufschlagen koennen
-// auf die neue Struktur mappen. Gibt es keinen Match, faellt die Settings-Page
-// einfach auf 'library' zurueck.
-const LEGACY_TAB_ALIAS: Record = {
- general: 'library',
- server: 'servers',
-};
-
-function resolveTab(input: string | undefined | null): Tab {
- if (!input) return 'servers';
- const aliased = LEGACY_TAB_ALIAS[input];
- if (aliased) return aliased;
- const known: Tab[] = ['library', 'servers', 'audio', 'lyrics', 'appearance', 'personalisation', 'integrations', 'input', 'storage', 'system', 'users'];
- return (known as string[]).includes(input) ? (input as Tab) : 'servers';
-}
-
-// Statischer Suchindex ueber alle Sub-Sections aller Tabs. Mitpflegen, wenn eine
-// neue SettingsSubSection hinzukommt — sonst taucht sie nicht in der Suche auf.
-type SearchIndexEntry = { tab: Tab; titleKey: string; keywords?: string };
-const SETTINGS_INDEX: SearchIndexEntry[] = [
- { tab: 'audio', titleKey: 'settings.audioOutputDevice', keywords: 'output device speakers headphones alsa wasapi coreaudio' },
- { tab: 'audio', titleKey: 'settings.hiResTitle', keywords: 'hi-res hires resampling bit depth sample rate dsd 24bit' },
- { tab: 'audio', titleKey: 'settings.eqTitle', keywords: 'equalizer eq bass treble autoeq filter pre-gain' },
- { tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade gapless replaygain replay gain volume' },
- { tab: 'lyrics', titleKey: 'settings.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' },
- { tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' },
- { tab: 'integrations', titleKey: 'settings.lfmTitle', keywords: 'last.fm lastfm scrobble' },
- { tab: 'integrations', titleKey: 'settings.discordRichPresence', keywords: 'discord rich presence rpc' },
- { tab: 'integrations', titleKey: 'settings.enableBandsintown', keywords: 'bandsintown concerts tours events' },
- { tab: 'integrations', titleKey: 'settings.nowPlayingEnabled', keywords: 'now playing share dropdown presence' },
- { tab: 'personalisation',titleKey: 'settings.sidebarTitle', keywords: 'sidebar nav navigation items reorder customize' },
- { tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' },
- { tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'home page customize sections' },
- { tab: 'personalisation',titleKey: 'settings.queueToolbarTitle', keywords: 'queue toolbar buttons reorder customize shuffle save load' },
- { tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
- { tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
- { tab: 'storage', titleKey: 'settings.offlineDirTitle', keywords: 'offline library download directory folder cache' },
- { tab: 'storage', titleKey: 'settings.nextTrackBufferingTitle', keywords: 'next track buffering preload hot cache streaming' },
- { tab: 'storage', titleKey: 'settings.downloadsTitle', keywords: 'downloads zip export archive folder' },
- { tab: 'appearance', titleKey: 'settings.theme', keywords: 'theme color palette dark light' },
- { tab: 'appearance', titleKey: 'settings.themeSchedulerTitle', keywords: 'theme scheduler auto time dark mode sunset' },
- { tab: 'appearance', titleKey: 'settings.visualOptionsTitle', keywords: 'visual options animations effects titlebar mini player' },
- { tab: 'appearance', titleKey: 'settings.uiScaleTitle', keywords: 'ui scale zoom dpi size' },
- { tab: 'appearance', titleKey: 'settings.font', keywords: 'font typography typeface' },
- { tab: 'appearance', titleKey: 'settings.fsPlayerSection', keywords: 'fullscreen player mesh blob' },
- { tab: 'appearance', titleKey: 'settings.seekbarStyle', keywords: 'seekbar progress bar waveform reduced animations performance gpu fps low-end framerate cap' },
- { tab: 'input', titleKey: 'settings.inputKeybindingsTitle', keywords: 'keybindings shortcuts hotkeys keyboard' },
- { tab: 'input', titleKey: 'settings.globalShortcutsTitle', keywords: 'global shortcuts hotkeys system-wide media keys' },
- { tab: 'system', titleKey: 'settings.language', keywords: 'language locale translation i18n' },
- { tab: 'system', titleKey: 'settings.behavior', keywords: 'behavior tray minimize close start smooth scroll linux' },
- { tab: 'system', titleKey: 'settings.backupTitle', keywords: 'backup export import settings restore' },
- { tab: 'system', titleKey: 'settings.loggingTitle', keywords: 'log logs diagnostic debug verbose' },
- { tab: 'system', titleKey: 'settings.aboutTitle', keywords: 'about version update changelog release notes' },
- { tab: 'system', titleKey: 'settings.aboutContributorsLabel', keywords: 'contributors credits maintainers' },
- { tab: 'system', titleKey: 'licenses.title', keywords: 'licenses license open source attribution copyright third party dependencies oss' },
-];
-
-// Substring-first, Fuzzy-Fallback (alle Query-Zeichen in Reihenfolge im
-// Haystack). Rueckgabe 0 = kein Match. Hoeher = besser.
-function matchScore(haystack: string, needle: string): number {
- if (!needle) return 0;
- const h = haystack.toLowerCase();
- const n = needle.toLowerCase();
- const idx = h.indexOf(n);
- if (idx >= 0) return 1000 - Math.min(999, idx);
- let hi = 0;
- for (const ch of n) {
- const j = h.indexOf(ch, hi);
- if (j < 0) return 0;
- hi = j + 1;
- }
- return Math.max(1, 100 - Math.min(99, hi - n.length));
-}
-
-
-function formatBytes(bytes: number): string {
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
- return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
-}
-
-/** Align hot-cache size slider (step 32 MB) to valid values. */
-function snapHotCacheMb(v: number): number {
- const x = Math.min(20000, Math.max(32, Math.round(v)));
- return Math.round((x - 32) / 32) * 32 + 32;
-}
-
-/** Makes raw ALSA device names more readable on Linux.
- * Values are kept as-is (rodio needs the ALSA name); only the displayed label is cleaned.
- * e.g. "sysdefault:CARD=U192k" → "U192k"
- * "hw:CARD=U192k,DEV=0" → "U192k (hw · PCM 0)"
- * "hdmi:CARD=NVidia,DEV=1" → "NVidia (HDMI · DEV 1)" (same DEV as in ALSA string)
- * "iec958:CARD=PCH,DEV=0" → "PCH (S/PDIF)"
- * Names without ALSA prefix (pipewire, pulse, default…) are returned unchanged. */
-function formatAudioDeviceLabel(name: string): string {
- const cardMatch = name.match(/CARD=([^,]+)/);
- if (!cardMatch) return name;
- const card = cardMatch[1];
- const devM = name.match(/DEV=(\d+)/);
- const devNum = devM ? parseInt(devM[1], 10) : null;
- const subM = name.match(/SUBDEV=(\d+)/);
- const subNum = subM ? parseInt(subM[1], 10) : null;
-
- if (name.startsWith('iec958:')) return `${card} (S/PDIF)`;
- if (name.startsWith('hdmi:')) {
- const d = devNum !== null ? devNum : 0;
- return `${card} (HDMI · DEV ${d})`;
- }
- if (name.startsWith('sysdefault:')) {
- if (devNum !== null && devNum > 0) return `${card} (default · PCM ${devNum})`;
- return card;
- }
- if (name.startsWith('plughw:')) {
- if (devNum !== null) {
- const sub = subNum !== null ? ` · sub ${subNum}` : '';
- return `${card} (plug · PCM ${devNum}${sub})`;
- }
- return card;
- }
- if (name.startsWith('hw:')) {
- if (devNum !== null) {
- const sub = subNum !== null ? ` · sub ${subNum}` : '';
- return `${card} (hw · PCM ${devNum}${sub})`;
- }
- return `${card} (hw)`;
- }
- if (name.startsWith('front:')) return `${card} (Front)`;
- if (name.startsWith('surround')) return `${card} (${name.split(':')[0]})`;
- // Other ALSA iface:card,dev — show plugin + PCM so identical cards differ
- const iface = name.split(':')[0];
- if (iface && !['default', 'pulse', 'pipewire'].includes(iface)) {
- if (devNum !== null) return `${card} (${iface} · PCM ${devNum})`;
- return `${card} (${iface})`;
- }
- return card;
-}
-
-/** Readable tail when two devices still share the same label (rare after formatAudioDeviceLabel). */
-function audioDeviceDuplicateHint(raw: string): string {
- const cardM = raw.match(/CARD=([^,]+)/);
- const devM = raw.match(/DEV=(\d+)/);
- const subM = raw.match(/SUBDEV=(\d+)/);
- const iface = raw.split(':')[0] || '';
- const parts: string[] = [];
- if (iface) parts.push(iface);
- if (cardM) parts.push(cardM[1]);
- if (devM) parts.push(`PCM ${devM[1]}`);
- if (subM) parts.push(`sub ${subM[1]}`);
- if (parts.length > 1) return parts.join(' · ');
- return raw.length > 56 ? `…${raw.slice(-53)}` : raw;
-}
-
-/** When several devices share the same display label, append a disambiguator. */
-function disambiguatedAudioDeviceLabel(raw: string, baseLabel: string, duplicateBase: boolean): string {
- if (!duplicateBase) return baseLabel;
- return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`;
-}
-
-/** cpal order is arbitrary; sort by readable label, current OS default first. */
-function sortAudioDeviceIds(devices: string[], osDefaultDeviceId: string | null): string[] {
- return [...devices].sort((a, b) => {
- const aDef = osDefaultDeviceId && a === osDefaultDeviceId;
- const bDef = osDefaultDeviceId && b === osDefaultDeviceId;
- if (aDef !== bDef) return aDef ? -1 : 1;
- const la = formatAudioDeviceLabel(a);
- const lb = formatAudioDeviceLabel(b);
- const byLabel = la.localeCompare(lb, undefined, { sensitivity: 'base' });
- if (byLabel !== 0) return byLabel;
- return a.localeCompare(b);
- });
-}
-
-function buildAudioDeviceSelectOptions(
- devices: string[],
- defaultLabel: string,
- osDefaultDeviceId: string | null,
- osDefaultMark: string,
- pinnedDevice: string | null,
- notInListSuffix: string,
-): { value: string; label: string }[] {
- const baseLabels = devices.map(formatAudioDeviceLabel);
- const countByBase = new Map();
- for (const b of baseLabels) countByBase.set(b, (countByBase.get(b) ?? 0) + 1);
- const pinned = pinnedDevice?.trim() || null;
- const pinnedNotListed = !!(pinned && !devices.includes(pinned));
- const ghost: { value: string; label: string }[] = pinnedNotListed
- ? (() => {
- const base = formatAudioDeviceLabel(pinned);
- let label = `${base} · ${notInListSuffix}`;
- if (osDefaultDeviceId && pinned === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
- return [{ value: pinned, label }];
- })()
- : [];
- return [
- { value: '', label: defaultLabel },
- ...ghost,
- ...devices.map((d, i) => {
- const base = baseLabels[i];
- const dup = (countByBase.get(base) ?? 0) > 1;
- let label = disambiguatedAudioDeviceLabel(d, base, dup);
- if (osDefaultDeviceId && d === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
- return { value: d, label };
- }),
- ];
-}
export default function Settings() {
const auth = useAuthStore();
diff --git a/src/utils/audioDeviceLabels.ts b/src/utils/audioDeviceLabels.ts
new file mode 100644
index 00000000..4954ed66
--- /dev/null
+++ b/src/utils/audioDeviceLabels.ts
@@ -0,0 +1,118 @@
+/** Makes raw ALSA device names more readable on Linux.
+ * Values are kept as-is (rodio needs the ALSA name); only the displayed label is cleaned.
+ * e.g. "sysdefault:CARD=U192k" → "U192k"
+ * "hw:CARD=U192k,DEV=0" → "U192k (hw · PCM 0)"
+ * "hdmi:CARD=NVidia,DEV=1" → "NVidia (HDMI · DEV 1)" (same DEV as in ALSA string)
+ * "iec958:CARD=PCH,DEV=0" → "PCH (S/PDIF)"
+ * Names without ALSA prefix (pipewire, pulse, default…) are returned unchanged. */
+export function formatAudioDeviceLabel(name: string): string {
+ const cardMatch = name.match(/CARD=([^,]+)/);
+ if (!cardMatch) return name;
+ const card = cardMatch[1];
+ const devM = name.match(/DEV=(\d+)/);
+ const devNum = devM ? parseInt(devM[1], 10) : null;
+ const subM = name.match(/SUBDEV=(\d+)/);
+ const subNum = subM ? parseInt(subM[1], 10) : null;
+
+ if (name.startsWith('iec958:')) return `${card} (S/PDIF)`;
+ if (name.startsWith('hdmi:')) {
+ const d = devNum !== null ? devNum : 0;
+ return `${card} (HDMI · DEV ${d})`;
+ }
+ if (name.startsWith('sysdefault:')) {
+ if (devNum !== null && devNum > 0) return `${card} (default · PCM ${devNum})`;
+ return card;
+ }
+ if (name.startsWith('plughw:')) {
+ if (devNum !== null) {
+ const sub = subNum !== null ? ` · sub ${subNum}` : '';
+ return `${card} (plug · PCM ${devNum}${sub})`;
+ }
+ return card;
+ }
+ if (name.startsWith('hw:')) {
+ if (devNum !== null) {
+ const sub = subNum !== null ? ` · sub ${subNum}` : '';
+ return `${card} (hw · PCM ${devNum}${sub})`;
+ }
+ return `${card} (hw)`;
+ }
+ if (name.startsWith('front:')) return `${card} (Front)`;
+ if (name.startsWith('surround')) return `${card} (${name.split(':')[0]})`;
+ // Other ALSA iface:card,dev — show plugin + PCM so identical cards differ
+ const iface = name.split(':')[0];
+ if (iface && !['default', 'pulse', 'pipewire'].includes(iface)) {
+ if (devNum !== null) return `${card} (${iface} · PCM ${devNum})`;
+ return `${card} (${iface})`;
+ }
+ return card;
+}
+
+/** Readable tail when two devices still share the same label (rare after formatAudioDeviceLabel). */
+export function audioDeviceDuplicateHint(raw: string): string {
+ const cardM = raw.match(/CARD=([^,]+)/);
+ const devM = raw.match(/DEV=(\d+)/);
+ const subM = raw.match(/SUBDEV=(\d+)/);
+ const iface = raw.split(':')[0] || '';
+ const parts: string[] = [];
+ if (iface) parts.push(iface);
+ if (cardM) parts.push(cardM[1]);
+ if (devM) parts.push(`PCM ${devM[1]}`);
+ if (subM) parts.push(`sub ${subM[1]}`);
+ if (parts.length > 1) return parts.join(' · ');
+ return raw.length > 56 ? `…${raw.slice(-53)}` : raw;
+}
+
+/** When several devices share the same display label, append a disambiguator. */
+export function disambiguatedAudioDeviceLabel(raw: string, baseLabel: string, duplicateBase: boolean): string {
+ if (!duplicateBase) return baseLabel;
+ return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`;
+}
+
+/** cpal order is arbitrary; sort by readable label, current OS default first. */
+export function sortAudioDeviceIds(devices: string[], osDefaultDeviceId: string | null): string[] {
+ return [...devices].sort((a, b) => {
+ const aDef = osDefaultDeviceId && a === osDefaultDeviceId;
+ const bDef = osDefaultDeviceId && b === osDefaultDeviceId;
+ if (aDef !== bDef) return aDef ? -1 : 1;
+ const la = formatAudioDeviceLabel(a);
+ const lb = formatAudioDeviceLabel(b);
+ const byLabel = la.localeCompare(lb, undefined, { sensitivity: 'base' });
+ if (byLabel !== 0) return byLabel;
+ return a.localeCompare(b);
+ });
+}
+
+export function buildAudioDeviceSelectOptions(
+ devices: string[],
+ defaultLabel: string,
+ osDefaultDeviceId: string | null,
+ osDefaultMark: string,
+ pinnedDevice: string | null,
+ notInListSuffix: string,
+): { value: string; label: string }[] {
+ const baseLabels = devices.map(formatAudioDeviceLabel);
+ const countByBase = new Map();
+ for (const b of baseLabels) countByBase.set(b, (countByBase.get(b) ?? 0) + 1);
+ const pinned = pinnedDevice?.trim() || null;
+ const pinnedNotListed = !!(pinned && !devices.includes(pinned));
+ const ghost: { value: string; label: string }[] = pinnedNotListed
+ ? (() => {
+ const base = formatAudioDeviceLabel(pinned);
+ let label = `${base} · ${notInListSuffix}`;
+ if (osDefaultDeviceId && pinned === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
+ return [{ value: pinned, label }];
+ })()
+ : [];
+ return [
+ { value: '', label: defaultLabel },
+ ...ghost,
+ ...devices.map((d, i) => {
+ const base = baseLabels[i];
+ const dup = (countByBase.get(base) ?? 0) > 1;
+ let label = disambiguatedAudioDeviceLabel(d, base, dup);
+ if (osDefaultDeviceId && d === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
+ return { value: d, label };
+ }),
+ ];
+}
diff --git a/src/utils/formatBytes.ts b/src/utils/formatBytes.ts
new file mode 100644
index 00000000..885a3826
--- /dev/null
+++ b/src/utils/formatBytes.ts
@@ -0,0 +1,11 @@
+export function formatBytes(bytes: number): string {
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
+}
+
+/** Align hot-cache size slider (step 32 MB) to valid values. */
+export function snapHotCacheMb(v: number): number {
+ const x = Math.min(20000, Math.max(32, Math.round(v)));
+ return Math.round((x - 32) / 32) * 32 + 32;
+}