import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { version as appVersion } from '../../package.json'; import { useNavigate, useLocation } from 'react-router-dom'; import { Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown, GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock, Users, UserPlus, Shield, Wand2, Search } from 'lucide-react'; import i18n from '../i18n'; import { exportBackup, importBackup } from '../utils/backup'; import { showToast } from '../utils/toast'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { open as openUrl } from '@tauri-apps/plugin-shell'; import { getImageCacheSize, clearImageCache } from '../utils/imageCache'; import { useOfflineStore } from '../store/offlineStore'; import { useHotCacheStore } from '../store/hotCacheStore'; import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; import CustomSelect from '../components/CustomSelect'; import SettingsSubSection from '../components/SettingsSubSection'; import { AboutPsysonicBrandHeader } from '../components/AboutPsysonicLol'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; import ThemePicker, { THEME_GROUPS } from '../components/ThemePicker'; import { useShallow } from 'zustand/react/shallow'; import { useAuthStore, ServerProfile, MIX_MIN_RATING_FILTER_MAX_STARS, type SeekbarStyle, type LyricsSourceId, type LyricsSourceConfig, type LoggingMode } from '../store/authStore'; import { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; import { useFontStore, FontId } from '../store/fontStore'; import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore'; import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore'; import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore'; import { useArtistLayoutStore, type ArtistSectionId, type ArtistSectionConfig } from '../store/artistLayoutStore'; import { useHomeStore, HomeSectionId } from '../store/homeStore'; import { useDragDrop, useDragSource } from '../contexts/DragDropContext'; import { ALL_NAV_ITEMS } from '../config/navItems'; import { applySidebarDropReorder } from '../utils/sidebarNavReorder'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import { ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser, ndListLibraries, ndSetUserLibraries, type NdUser, type NdLibrary, } from '../api/navidromeAdmin'; import { switchActiveServer } from '../utils/switchActiveServer'; import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog'; import ConfirmModal from '../components/ConfirmModal'; import { Trans, useTranslation } from 'react-i18next'; import Equalizer from '../components/Equalizer'; import StarRating from '../components/StarRating'; import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity'; import { decodeServerMagicString, encodeServerMagicString, copyTextToClipboard, DECODED_PASSWORD_VISUAL_MASK, type ServerMagicPayload, } from '../utils/serverMagicString'; import { shortHostFromServerUrl, serverListDisplayLabel } from '../utils/serverDisplayName'; const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature']; const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; 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)', 'Linux: stop Wayland GTK drag proxy and PsyDnD ghost (PR #268)', 'Sidebar: long-press drag to reorder and hide nav items (PR #269)', 'Sleep timer and delayed start via long-press on play/pause (PR #270)', 'Lucky Mix — instant queue from listening history, ratings, and AudioMuse similar tracks (PR #278)', ], }, { 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)', ], }, { 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)', ], }, ] 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: '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' }, { 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' }, ]; // 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 AddServerForm({ onSave, onCancel, initialInvite = null, }: { onSave: (data: Omit) => void; onCancel: () => void; initialInvite?: ServerMagicPayload | null; }) { const { t } = useTranslation(); const [form, setForm] = useState({ name: '', url: '', username: '', password: '' }); const [magicString, setMagicString] = useState(''); const [showPass, setShowPass] = useState(false); const [blockPasswordReveal, setBlockPasswordReveal] = useState(false); useEffect(() => { if (!initialInvite) return; setShowPass(false); setBlockPasswordReveal(true); setForm({ name: (initialInvite.name && initialInvite.name.trim()) || shortHostFromServerUrl(initialInvite.url), url: initialInvite.url, username: initialInvite.username, password: initialInvite.password, }); setMagicString(encodeServerMagicString(initialInvite)); }, [initialInvite]); const update = (k: keyof typeof form) => (e: React.ChangeEvent) => setForm(f => ({ ...f, [k]: e.target.value })); const handleMagicStringChange = (e: React.ChangeEvent) => { const v = e.target.value; setMagicString(v); const trimmed = v.trim(); const decoded = decodeServerMagicString(trimmed); if (decoded) { setShowPass(false); setBlockPasswordReveal(true); setForm({ name: (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url), url: decoded.url, username: decoded.username, password: decoded.password, }); } }; const submit = () => { const ms = magicString.trim(); if (ms) { const decoded = decodeServerMagicString(ms); if (!decoded) { showToast(t('login.magicStringInvalid'), 4000, 'error'); return; } onSave({ name: form.name.trim() || (decoded.name && decoded.name.trim()) || shortHostFromServerUrl(decoded.url), url: decoded.url, username: decoded.username, password: decoded.password, }); return; } if (!form.url.trim()) return; onSave({ name: form.name.trim() || form.url.trim(), url: form.url.trim(), username: form.username.trim(), password: form.password, }); }; return (

{t('settings.addServerTitle')}

{blockPasswordReveal ? ( ) : (
)}
); } interface UserFormState { userName: string; name: string; email: string; password: string; isAdmin: boolean; libraryIds: number[]; } function initialUserFormState(u: NdUser | undefined, allLibraries: NdLibrary[]): UserFormState { const defaultIds = allLibraries.map(l => l.id); return { userName: u?.userName ?? '', name: u?.name ?? '', email: u?.email ?? '', password: '', isAdmin: !!u?.isAdmin, libraryIds: u ? [...u.libraryIds] : defaultIds, }; } function UserForm({ initial, libraries, shareServerUrl, ndToken, onUsersDirty, onSave, onSaveAndGetMagic, onCancel, busy, }: { initial: NdUser | null; libraries: NdLibrary[]; shareServerUrl: string; ndToken: string; onUsersDirty?: () => void | Promise; onSave: (form: UserFormState) => void; /** New user only: create on Navidrome then copy magic string to clipboard. */ onSaveAndGetMagic?: (form: UserFormState) => void | Promise; onCancel: () => void; busy: boolean; }) { const { t } = useTranslation(); const [form, setForm] = useState(() => initialUserFormState(initial ?? undefined, libraries)); const [showPass, setShowPass] = useState(false); const [magicGenBusy, setMagicGenBusy] = useState(false); const [showNewUserRequiredErrors, setShowNewUserRequiredErrors] = useState(false); const isEdit = !!initial; useEffect(() => { setShowNewUserRequiredErrors(false); }, [initial?.id]); useEffect(() => { if (!isEdit && form.userName.trim() && form.name.trim() && form.password.trim()) { setShowNewUserRequiredErrors(false); } }, [isEdit, form.userName, form.name, form.password]); const set = (k: K, v: UserFormState[K]) => setForm(f => ({ ...f, [k]: v })); const toggleLib = (id: number) => setForm(f => ({ ...f, libraryIds: f.libraryIds.includes(id) ? f.libraryIds.filter(x => x !== id) : [...f.libraryIds, id], })); const newUserPasswordOk = form.password.trim().length > 0; const canSave = form.userName.trim().length > 0 && form.name.trim().length > 0 && (isEdit || newUserPasswordOk) && (form.isAdmin || form.libraryIds.length > 0); const generateMagicString = async () => { if (!shareServerUrl.trim() || !form.password.trim() || !initial || !ndToken.trim()) return; setMagicGenBusy(true); try { await ndUpdateUser(shareServerUrl.trim(), ndToken, initial.id, { userName: form.userName.trim(), name: form.name.trim(), email: form.email.trim(), password: form.password, isAdmin: form.isAdmin, }); } catch (e) { const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null); showToast(msg ?? t('settings.userMgmtUpdateError'), 5000, 'error'); return; } finally { setMagicGenBusy(false); } const str = encodeServerMagicString({ url: shareServerUrl.trim(), username: form.userName.trim(), password: form.password, name: shortHostFromServerUrl(shareServerUrl), }); const ok = await copyTextToClipboard(str); showToast( ok ? t('settings.userMgmtMagicStringCopied') : t('settings.userMgmtMagicStringCopyFailed'), ok ? 3000 : 5000, ok ? 'info' : 'error', ); if (ok) void onUsersDirty?.(); }; const runSaveAndGetMagic = async () => { if (!onSaveAndGetMagic) return; if (!form.userName.trim() || !form.name.trim() || !form.password.trim()) { setShowNewUserRequiredErrors(true); showToast(t('settings.userMgmtValidationMissing'), 4000, 'error'); return; } if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) { showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error'); return; } setMagicGenBusy(true); try { await onSaveAndGetMagic(form); } finally { setMagicGenBusy(false); } }; const invalidNewUserCore = !isEdit && (!form.userName.trim() || !form.name.trim() || !form.password.trim()); const trySave = () => { if (invalidNewUserCore) { setShowNewUserRequiredErrors(true); showToast(t('settings.userMgmtValidationMissing'), 4000, 'error'); return; } onSave(form); }; const markInvalid = showNewUserRequiredErrors && !isEdit; return (

{isEdit ? t('settings.userMgmtEditUserTitle') : t('settings.userMgmtAddUserTitle')}

set('userName', e.target.value)} disabled={isEdit} autoComplete="off" aria-invalid={markInvalid && !form.userName.trim()} style={markInvalid && !form.userName.trim() ? { borderColor: 'var(--danger)' } : undefined} />
set('name', e.target.value)} autoComplete="off" aria-invalid={markInvalid && !form.name.trim()} style={markInvalid && !form.name.trim() ? { borderColor: 'var(--danger)' } : undefined} />
set('email', e.target.value)} autoComplete="off" />
set('password', e.target.value)} placeholder="••••••••" autoComplete="new-password" aria-invalid={markInvalid && !form.password.trim()} style={{ paddingRight: '2.5rem', ...(markInvalid && !form.password.trim() ? { borderColor: 'var(--danger)' } : {}), }} />
{isEdit && (
{t('settings.userMgmtPasswordEditHint')}
)}
{form.isAdmin ? (
{t('settings.userMgmtLibrariesAdminHint')}
) : libraries.length === 0 ? (
{t('settings.userMgmtLibrariesEmpty')}
) : ( <>
{libraries.map(lib => ( ))}
{form.libraryIds.length === 0 && (
{t('settings.userMgmtLibrariesValidation')}
)} )}
{!form.isAdmin && !isEdit && onSaveAndGetMagic && shareServerUrl.trim() && ndToken.trim() && (
{t('settings.userMgmtMagicStringPlaintextWarning')}
)} {!form.isAdmin && isEdit && shareServerUrl.trim() && form.password.trim().length > 0 && ndToken.trim() && (
{t('settings.userMgmtMagicStringPasswordNavHint')}
{t('settings.userMgmtMagicStringPlaintextWarning')}
)}
); } function formatLastSeen(iso: string | null | undefined, locale: string, neverLabel: string): string { if (!iso) return neverLabel; const t = new Date(iso).getTime(); // Navidrome returns "0001-01-01T00:00:00Z" for never-accessed users → guard against bogus epochs. if (!Number.isFinite(t) || t < 1_000_000_000_000) return neverLabel; const diffSec = (t - Date.now()) / 1000; const abs = Math.abs(diffSec); const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); if (abs < 60) return rtf.format(Math.round(diffSec), 'second'); if (abs < 3600) return rtf.format(Math.round(diffSec / 60), 'minute'); if (abs < 86400) return rtf.format(Math.round(diffSec / 3600), 'hour'); if (abs < 604800) return rtf.format(Math.round(diffSec / 86400), 'day'); if (abs < 2592000) return rtf.format(Math.round(diffSec / 604800), 'week'); if (abs < 31536000) return rtf.format(Math.round(diffSec / 2592000), 'month'); return rtf.format(Math.round(diffSec / 31536000), 'year'); } function UserManagementSection({ serverUrl, token, currentUsername, }: { serverUrl: string; token: string; currentUsername: string; }) { const { t, i18n } = useTranslation(); const [users, setUsers] = useState([]); const [libraries, setLibraries] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [editing, setEditing] = useState(null); const [confirmingDelete, setConfirmingDelete] = useState(null); const [busy, setBusy] = useState(false); const [magicRowUser, setMagicRowUser] = useState(null); const [magicRowPassword, setMagicRowPassword] = useState(''); const [magicRowSubmitting, setMagicRowSubmitting] = useState(false); const load = useCallback(async () => { setLoading(true); setLoadError(null); try { // Sequential, not parallel: nginx setups with churning upstream // keep-alive drop one of the two parallel TLS connections. Doing // users first then libraries keeps us on one connection at a time // and pairs cleanly with the nd_retry backoff on the Rust side. const list = await ndListUsers(serverUrl, token); const libs = await ndListLibraries(serverUrl, token).catch(() => [] as NdLibrary[]); setUsers([...list].sort((a, b) => a.userName.localeCompare(b.userName))); setLibraries([...libs].sort((a, b) => a.name.localeCompare(b.name))); } catch (e) { // Tauri invoke rejects with a plain string (our Rust returns Err(String)), // not an Error instance. Normalise so the surfaced message is the real // cause (e.g. "tls handshake eof") rather than the generic i18n fallback. const raw = typeof e === 'string' ? e : (e instanceof Error && e.message) ? e.message : ''; const prefix = t('settings.userMgmtLoadError'); setLoadError(raw ? `${prefix} ${raw}` : prefix); } finally { setLoading(false); } }, [serverUrl, token, t]); useEffect(() => { void load(); }, [load]); const handleSave = async (form: UserFormState) => { const userName = form.userName.trim(); const name = form.name.trim(); const email = form.email.trim(); if (editing === 'new') { if (!userName || !name || !form.password.trim()) { showToast(t('settings.userMgmtValidationMissing'), 4000, 'error'); return; } } else if (editing) { if (!userName || !name) { showToast(t('settings.userMgmtValidationMissingIdentity'), 4000, 'error'); return; } } if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) { showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error'); return; } if (!token) return; setBusy(true); try { let targetId: string; if (editing === 'new') { const created = await ndCreateUser(serverUrl, token, { userName, name, email, password: form.password, isAdmin: form.isAdmin, }); targetId = created.id; showToast(t('settings.userMgmtCreated'), 3000, 'info'); } else if (editing) { await ndUpdateUser(serverUrl, token, editing.id, { userName, name, email, password: form.password, isAdmin: form.isAdmin, }); targetId = editing.id; showToast(t('settings.userMgmtUpdated'), 3000, 'info'); } else { return; } if (!form.isAdmin && form.libraryIds.length > 0) { try { await ndSetUserLibraries(serverUrl, token, targetId, form.libraryIds); } catch (e) { const msg = (e instanceof Error && e.message) ? e.message : String(e); showToast(`${t('settings.userMgmtLibrariesUpdateError')}: ${msg}`, 5000, 'error'); } } setEditing(null); await load(); } catch (e) { const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null); const fallback = editing === 'new' ? t('settings.userMgmtCreateError') : t('settings.userMgmtUpdateError'); showToast(msg ?? fallback, 5000, 'error'); } finally { setBusy(false); } }; const handleSaveAndGetMagic = async (form: UserFormState) => { if (editing !== 'new' || form.isAdmin) return; const userName = form.userName.trim(); const name = form.name.trim(); const email = form.email.trim(); if (!userName || !name || !form.password.trim()) { showToast(t('settings.userMgmtValidationMissing'), 4000, 'error'); return; } if (!form.isAdmin && form.libraryIds.length === 0 && libraries.length > 0) { showToast(t('settings.userMgmtLibrariesValidation'), 4000, 'error'); return; } if (!token) return; setBusy(true); try { const created = await ndCreateUser(serverUrl, token, { userName, name, email, password: form.password, isAdmin: form.isAdmin, }); const targetId = created.id; showToast(t('settings.userMgmtCreated'), 3000, 'info'); if (!form.isAdmin && form.libraryIds.length > 0) { try { await ndSetUserLibraries(serverUrl, token, targetId, form.libraryIds); } catch (e) { const msg = (e instanceof Error && e.message) ? e.message : String(e); showToast(`${t('settings.userMgmtLibrariesUpdateError')}: ${msg}`, 5000, 'error'); } } const str = encodeServerMagicString({ url: serverUrl.trim(), username: userName, password: form.password, name: shortHostFromServerUrl(serverUrl), }); const ok = await copyTextToClipboard(str); showToast( ok ? t('settings.userMgmtMagicStringCopied') : t('settings.userMgmtMagicStringCopyFailed'), ok ? 3000 : 5000, ok ? 'info' : 'error', ); setEditing(null); await load(); } catch (e) { const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : null); showToast(msg ?? t('settings.userMgmtCreateError'), 5000, 'error'); } finally { setBusy(false); } }; const performDelete = async (u: NdUser) => { if (!token) return; setConfirmingDelete(null); setBusy(true); try { await ndDeleteUser(serverUrl, token, u.id); showToast(t('settings.userMgmtDeleted'), 3000, 'info'); await load(); } catch (e) { const msg = (e instanceof Error && e.message) ? e.message : (typeof e === 'string' ? e : t('settings.userMgmtDeleteError')); showToast(msg, 5000, 'error'); } finally { setBusy(false); } }; return (

{t('settings.userMgmtTitle')}

{t('settings.userMgmtDesc')}
{loading && (
)} {!loading && loadError && (
{t('settings.userMgmtLoadFriendly')}
{loadError}
)} {!loading && !loadError && ( <> {editing ? ( setEditing(null)} busy={busy} /> ) : ( )} {users.length === 0 ? (
{t('settings.userMgmtEmpty')}
) : (
{users.map(u => { const isSelf = u.userName === currentUsername; const libNames = u.isAdmin ? null : u.libraryIds.length === 0 ? t('settings.userMgmtNoLibraries') : libraries.filter(l => u.libraryIds.includes(l.id)).map(l => l.name).join(', '); const lastSeen = formatLastSeen(u.lastAccessAt, i18n.language, t('settings.userMgmtNeverSeen')); const lastSeenAbsolute = u.lastAccessAt ? new Date(u.lastAccessAt).toLocaleString(i18n.language) : ''; return (
{ if (!busy) setEditing(u); }} onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && !busy) { e.preventDefault(); setEditing(u); } }} style={{ padding: '6px 10px', display: 'flex', alignItems: 'center', gap: 10, cursor: busy ? 'default' : 'pointer', }} > {u.userName} {u.name && u.name !== u.userName && ( · {u.name} )} {isSelf && ( {t('settings.userMgmtYouBadge')} )} {u.isAdmin && ( {t('settings.userMgmtAdminBadge')} )} {libNames || ''} {!u.isAdmin && ( )} {lastSeen}
); })}
)} )} { if (confirmingDelete) void performDelete(confirmingDelete); }} onCancel={() => setConfirmingDelete(null)} /> {magicRowUser && createPortal(
!magicRowSubmitting && setMagicRowUser(null)} role="dialog" aria-modal="true" style={{ alignItems: 'center', paddingTop: 0 }} >
e.stopPropagation()} style={{ maxWidth: '400px' }} >

{t('settings.userMgmtMagicStringModalTitle')}

{t('settings.userMgmtMagicStringModalDesc', { username: magicRowUser.userName })}

{t('settings.userMgmtMagicStringPasswordNavHint')}

{t('settings.userMgmtMagicStringPlaintextWarning')}
setMagicRowPassword(e.target.value)} autoComplete="off" disabled={magicRowSubmitting} />
, document.body, )}
); } 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(); const theme = useThemeStore(); const fontStore = useFontStore(); const kb = useKeybindingsStore(); const gs = useGlobalShortcutsStore(); const serverId = auth.activeServerId ?? ''; const clearAllOffline = useOfflineStore(s => s.clearAll); const clearHotCacheDisk = useHotCacheStore(s => s.clearAllDisk); const hotCacheEntries = useHotCacheStore(s => s.entries); const [isTilingWm, setIsTilingWm] = useState(false); useEffect(() => { if (!IS_LINUX) return; invoke('is_tiling_wm_cmd').then(setIsTilingWm).catch(() => {}); }, []); const hotCacheTrackCount = useMemo(() => { if (!serverId) return 0; const prefix = `${serverId}:`; return Object.keys(hotCacheEntries).filter(k => k.startsWith(prefix)).length; }, [hotCacheEntries, serverId]); const [listeningFor, setListeningFor] = useState(null); const [listeningForGlobal, setListeningForGlobal] = useState(null); const navigate = useNavigate(); const location = useLocation(); const routeState = location.state; const { t, i18n } = useTranslation(); const [activeTab, setActiveTab] = useState(resolveTab((routeState as { tab?: string } | null)?.tab)); const [searchQuery, setSearchQuery] = useState(''); const [searchOpen, setSearchOpen] = useState(false); const [searchResults, setSearchResults] = useState>([]); const [selectedResultIdx, setSelectedResultIdx] = useState(0); const [pendingFocusTitle, setPendingFocusTitle] = useState(null); const searchInputRef = useRef(null); const searchResultsListRef = useRef(null); // Server-Liste DnD const psyDragState = useDragDrop(); const [serverContainerEl, setServerContainerEl] = useState(null); const [serverDropTarget, setServerDropTarget] = useState(null); const serverDropTargetRef = useRef(null); const serversRef = useRef(auth.servers); serversRef.current = auth.servers; const [connStatus, setConnStatus] = useState>({}); const [showAddForm, setShowAddForm] = useState(false); const [pastedServerInvite, setPastedServerInvite] = useState(null); const [newGenre, setNewGenre] = useState(''); const [lfmState, setLfmState] = useState<'idle' | 'waiting' | 'error'>('idle'); const [lfmPendingToken, setLfmPendingToken] = useState(null); const [lfmError, setLfmError] = useState(null); const [lfmUserInfo, setLfmUserInfo] = useState(null); const [imageCacheBytes, setImageCacheBytes] = useState(null); const [offlineCacheBytes, setOfflineCacheBytes] = useState(null); const [hotCacheBytes, setHotCacheBytes] = useState(null); const [audioDevices, setAudioDevices] = useState([]); const [osDefaultAudioDeviceId, setOsDefaultAudioDeviceId] = useState(null); const [deviceSwitching, setDeviceSwitching] = useState(false); const [devicesLoading, setDevicesLoading] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); const [clearing, setClearing] = useState(false); const [fontPickerOpen, setFontPickerOpen] = useState(false); const [ndAdminAuth, setNdAdminAuth] = useState<{ token: string; serverUrl: string; username: string } | null>(null); const [ndAuthChecked, setNdAuthChecked] = useState(false); const addServerInviteAnchorRef = useRef(null); useLayoutEffect(() => { if (!showAddForm || !pastedServerInvite) return; addServerInviteAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, [showAddForm, pastedServerInvite]); useEffect(() => { const st = routeState as { openAddServerInvite?: ServerMagicPayload; tab?: Tab } | null; const inv = st?.openAddServerInvite; if (inv) { setPastedServerInvite(inv); setShowAddForm(true); setActiveTab('servers'); navigate( { pathname: location.pathname, search: location.search, hash: location.hash }, { replace: true, state: { tab: 'servers' as Tab } }, ); return; } if (st?.tab) setActiveTab(st.tab); }, [routeState, location.pathname, location.search, location.hash, navigate]); // Settings-Suche: matcht SETTINGS_INDEX gegen den Query (Substring + Fuzzy). // Ergebnis ist eine flache Liste; aktueller Tab zuerst, dann nach Score. Wenn // eine Query aktiv ist, wird der Tab-Content gerendert-nicht und stattdessen // die Ergebnisliste angezeigt. useEffect(() => { const q = searchQuery.trim(); if (!q) { setSearchResults([]); return; } const scored = SETTINGS_INDEX.map(entry => { const title = t(entry.titleKey as any); const hay = entry.keywords ? `${title} ${entry.keywords}` : title; return { ...entry, title, score: matchScore(hay, q) }; }).filter(e => e.score > 0); scored.sort((a, b) => { const aCurrent = a.tab === activeTab ? 1 : 0; const bCurrent = b.tab === activeTab ? 1 : 0; if (aCurrent !== bCurrent) return bCurrent - aCurrent; return b.score - a.score; }); setSearchResults(scored); setSelectedResultIdx(0); }, [searchQuery, activeTab, t]); // Selektion ins Blickfeld scrollen (nur wenn das Item out-of-view ist). useEffect(() => { if (!searchQuery || searchResults.length === 0) return; const list = searchResultsListRef.current; if (!list) return; const item = list.children[selectedResultIdx] as HTMLElement | undefined; item?.scrollIntoView({ block: 'nearest' }); }, [selectedResultIdx, searchQuery, searchResults.length]); // Ctrl/Cmd+F oeffnet die Settings-Suche (nur auf der Settings-Seite — dieser // Effect ist ja an Settings gebunden). Fokussiert das Feld auch wenn's schon // offen ist. preventDefault blockt die native WebKit-Find-Bar. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== 'f' && e.key !== 'F') return; if (!(e.ctrlKey || e.metaKey)) return; if (e.altKey || e.shiftKey) return; e.preventDefault(); setSearchOpen(true); window.setTimeout(() => { searchInputRef.current?.focus(); searchInputRef.current?.select(); }, 0); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, []); // Nach Klick auf ein Ergebnis: Ziel-Sub-Section oeffnen, scrollen und kurz // highlighten, damit der User auf dem neuen Tab sofort weiss welcher Eintrag // gemeint war. useEffect(() => { if (!pendingFocusTitle) return; const el = document.querySelector( `[data-settings-search="${CSS.escape(pendingFocusTitle)}"]`, ); if (!el) return; if (el instanceof HTMLDetailsElement) el.open = true; el.scrollIntoView({ behavior: 'smooth', block: 'start' }); el.classList.remove('settings-sub-section--flash'); // reflow, damit die Animation bei wiederholtem Klick auf dasselbe Ziel // erneut abspielt. void el.offsetWidth; el.classList.add('settings-sub-section--flash'); const timer = window.setTimeout(() => { el.classList.remove('settings-sub-section--flash'); }, 1500); setPendingFocusTitle(null); return () => window.clearTimeout(timer); }, [pendingFocusTitle, activeTab]); useEffect(() => { const server = auth.getActiveServer(); setNdAuthChecked(false); if (!server) { setNdAdminAuth(null); setNdAuthChecked(true); return; } const serverUrl = (server.url.startsWith('http') ? server.url : `http://${server.url}`).replace(/\/$/, ''); let cancelled = false; ndLogin(serverUrl, server.username, server.password) .then(res => { if (cancelled) return; setNdAdminAuth(res.isAdmin ? { token: res.token, serverUrl, username: server.username } : null); }) .catch(() => { if (!cancelled) setNdAdminAuth(null); }) .finally(() => { if (!cancelled) setNdAuthChecked(true); }); return () => { cancelled = true; }; }, [auth.activeServerId]); useEffect(() => { if (activeTab === 'users' && ndAuthChecked && ndAdminAuth === null) setActiveTab('servers'); }, [activeTab, ndAdminAuth, ndAuthChecked]); useEffect(() => { if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; } lastfmGetUserInfo(auth.lastfmUsername, auth.lastfmSessionKey).then(setLfmUserInfo).catch(() => {}); }, [auth.lastfmSessionKey, auth.lastfmUsername]); useEffect(() => { if (activeTab !== 'storage') return; getImageCacheSize().then(setImageCacheBytes); invoke('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).then(setOfflineCacheBytes).catch(() => setOfflineCacheBytes(0)); invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); }, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]); const refreshAudioDevices = useCallback((opts?: { silent?: boolean }) => { const silent = !!opts?.silent; if (!silent) setDevicesLoading(true); const listP = invoke('audio_list_devices').catch((e) => { console.error(e); showToast(t('settings.audioOutputDeviceListError'), 5000, 'error'); return [] as string[]; }); const defP = invoke('audio_default_output_device_name').catch(() => null); Promise.all([listP, defP]) .then(async ([devices, osDefault]) => { let canon: string | null = null; try { canon = await invoke('audio_canonicalize_selected_device'); if (canon) useAuthStore.getState().setAudioOutputDevice(canon); } catch { /* ignore */ } const finalList = canon ? await invoke('audio_list_devices').catch(() => devices) : devices; const defId = osDefault ?? null; setAudioDevices(sortAudioDeviceIds(finalList, defId)); setOsDefaultAudioDeviceId(defId); }) .finally(() => { if (!silent) setDevicesLoading(false); }); }, [t]); // Load available audio output devices when Audio tab opens. // Skipped on macOS — the stream is pinned to the system default (see // audioOutputDeviceMacNotice) so there is no picker to populate. useEffect(() => { if (activeTab !== 'audio' || IS_MACOS) return; refreshAudioDevices(); }, [activeTab, refreshAudioDevices]); // Keep device list + "current system output" mark in sync when the backend reopens the stream. useEffect(() => { if (activeTab !== 'audio' || IS_MACOS) return; let cancelled = false; const unlisteners: Array<() => void> = []; (async () => { for (const ev of ['audio:device-changed', 'audio:device-reset'] as const) { const u = await listen(ev, () => { if (!cancelled) refreshAudioDevices({ silent: true }); }); if (cancelled) { u(); return; } unlisteners.push(u); } })(); return () => { cancelled = true; for (const u of unlisteners) u(); }; }, [activeTab, refreshAudioDevices]); /** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */ useEffect(() => { if (activeTab !== 'audio') return; const customDir = auth.hotCacheDownloadDir || null; const refresh = () => { invoke('get_hot_cache_size', { customDir }) .then(setHotCacheBytes) .catch(() => setHotCacheBytes(0)); }; refresh(); if (!auth.hotCacheEnabled) return; const interval = window.setInterval(refresh, 2000); return () => window.clearInterval(interval); }, [activeTab, auth.hotCacheEnabled, auth.hotCacheDownloadDir]); useEffect(() => { if (activeTab !== 'audio' || !auth.hotCacheEnabled) return; const t = window.setTimeout(() => { invoke('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }) .then(setHotCacheBytes) .catch(() => setHotCacheBytes(0)); }, 400); return () => window.clearTimeout(t); }, [hotCacheEntries, activeTab, auth.hotCacheEnabled, auth.hotCacheDownloadDir]); const handleClearCache = useCallback(async () => { setClearing(true); await clearImageCache(); await clearAllOffline(serverId); const [imgBytes, offBytes] = await Promise.all([ getImageCacheSize(), invoke('get_offline_cache_size', { customDir: auth.offlineDownloadDir || null }).catch(() => 0), ]); setImageCacheBytes(imgBytes); setOfflineCacheBytes(offBytes); setShowClearConfirm(false); setClearing(false); }, [clearAllOffline, serverId]); const startLastfmConnect = useCallback(async () => { setLfmError(null); let token: string; try { token = await lastfmGetToken(); setLfmPendingToken(token); setLfmState('waiting'); await openUrl(lastfmAuthUrl(token)); } catch (e: any) { setLfmError(e.message ?? 'Unknown error'); setLfmState('error'); return; } // Poll every 2 s until the user authorises or we time out (2 min) const deadline = Date.now() + 120_000; const poll = async () => { if (Date.now() > deadline) { setLfmState('error'); setLfmError('Timed out — please try again.'); setLfmPendingToken(null); return; } try { const { key, name } = await lastfmGetSession(token); auth.connectLastfm(key, name); setLfmState('idle'); setLfmPendingToken(null); } catch (e: any) { // Error 14 = not yet authorised, keep polling if (e.message?.includes('14')) { setTimeout(poll, 2000); } else { setLfmState('error'); setLfmError(e.message ?? 'Unknown error'); setLfmPendingToken(null); } } }; setTimeout(poll, 2000); }, [auth]); const testConnection = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); try { const ping = await pingWithCredentials(server.url, server.username, server.password); if (ping.ok) { const identity = { type: ping.type, serverVersion: ping.serverVersion, openSubsonic: ping.openSubsonic, }; auth.setSubsonicServerIdentity(server.id, identity); scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity); } setConnStatus(s => ({ ...s, [server.id]: ping.ok ? 'ok' : 'error' })); } catch { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } }; // Clear drop target when drag ends useEffect(() => { if (!psyDragState.isDragging) { serverDropTargetRef.current = null; setServerDropTarget(null); } }, [psyDragState.isDragging]); // psy-drop listener for server reorder useEffect(() => { if (!serverContainerEl) return; const onPsyDrop = (e: Event) => { const detail = (e as CustomEvent).detail; if (!detail?.data) return; let parsed: { type?: string; index?: number }; try { parsed = JSON.parse(detail.data as string); } catch { return; } if (parsed.type !== 'server_reorder' || parsed.index == null) return; const fromIdx = parsed.index; const target = serverDropTargetRef.current; serverDropTargetRef.current = null; setServerDropTarget(null); if (!target) return; const insertBefore = target.before ? target.idx : target.idx + 1; if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; const next = [...serversRef.current]; const [moved] = next.splice(fromIdx, 1); next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); auth.setServers(next); }; serverContainerEl.addEventListener('psy-drop', onPsyDrop); return () => serverContainerEl.removeEventListener('psy-drop', onPsyDrop); }, [serverContainerEl, auth]); const handleServerDragMove = (e: React.MouseEvent) => { if (!psyDragState.isDragging || !serverContainerEl) return; const rows = serverContainerEl.querySelectorAll('[data-server-idx]'); let target: ServerDropTarget = null; for (const row of rows) { const rect = row.getBoundingClientRect(); const idx = Number(row.dataset.serverIdx); if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } target = { idx, before: false }; } serverDropTargetRef.current = target; setServerDropTarget(target); }; const switchToServer = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); const ok = await switchActiveServer(server); if (ok) { setConnStatus(s => ({ ...s, [server.id]: 'ok' })); // Auf der Servers-Seite bleiben, damit der User seinen Switch hier // sofort visuell bestaetigt sieht (gruener Check, aktiv-Badge). } else { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } }; const deleteServer = (server: ServerProfile) => { if (confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) { auth.removeServer(server.id); } }; const closeAddServerForm = () => { setShowAddForm(false); setPastedServerInvite(null); }; const handleAddServer = async (data: Omit) => { setShowAddForm(false); setPastedServerInvite(null); const tempId = '_new'; setConnStatus(s => ({ ...s, [tempId]: 'testing' })); try { const ping = await pingWithCredentials(data.url, data.username, data.password); if (ping.ok) { const id = auth.addServer(data); const identity = { type: ping.type, serverVersion: ping.serverVersion, openSubsonic: ping.openSubsonic, }; auth.setSubsonicServerIdentity(id, identity); scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity); auth.setActiveServer(id); auth.setLoggedIn(true); setConnStatus(s => ({ ...s, [id]: 'ok' })); } else { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } } catch { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } }; const handleLogout = () => { auth.logout(); navigate('/login'); }; const pickOfflineDir = async () => { const selected = await openDialog({ directory: true, multiple: false, title: t('settings.offlineDirChange') }); if (selected && typeof selected === 'string') { auth.setOfflineDownloadDir(selected); } }; const pickHotCacheDir = async () => { const selected = await openDialog({ directory: true, multiple: false, title: t('settings.hotCacheDirChange') }); if (selected && typeof selected === 'string') { auth.setHotCacheDownloadDir(selected); useHotCacheStore.setState({ entries: {} }); invoke('get_hot_cache_size', { customDir: selected }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); } }; const pickDownloadFolder = async () => { const selected = await openDialog({ directory: true, multiple: false, title: t('settings.pickFolderTitle') }); if (selected && typeof selected === 'string') { auth.setDownloadFolder(selected); } }; const exportRuntimeLogs = async () => { const suggestedName = `psysonic-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.log`; const selected = await saveDialog({ defaultPath: suggestedName, filters: [{ name: 'Log files', extensions: ['log', 'txt'] }], title: t('settings.loggingExport'), }); if (!selected || Array.isArray(selected)) return; try { const lines = await invoke('export_runtime_logs', { path: selected }); showToast(t('settings.loggingExportSuccess', { count: lines }), 3500, 'info'); } catch (e) { console.error(e); showToast(t('settings.loggingExportError'), 4500, 'error'); } }; const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ { id: 'servers', label: t('settings.tabServers'), icon: }, { id: 'library', label: t('settings.tabLibrary'), icon: }, { id: 'audio', label: t('settings.tabAudio'), icon: }, { id: 'lyrics', label: t('settings.tabLyrics'), icon: }, { id: 'appearance', label: t('settings.tabAppearance'), icon: }, { id: 'personalisation', label: t('settings.tabPersonalisation'), icon: }, { id: 'integrations', label: t('settings.tabIntegrations'), icon: }, { id: 'input', label: t('settings.tabInput'), icon: }, { id: 'storage', label: t('settings.tabStorage'), icon: }, { id: 'system', label: t('settings.tabSystem'), icon: }, ...(ndAdminAuth ? [{ id: 'users' as Tab, label: t('settings.tabUsers'), icon: }] : []), ]; return (

{t('settings.title')}

{!searchOpen ? ( ) : (
)}
{/* Tab navigation */} {searchQuery && searchResults.length === 0 && (
{t('settings.searchNoResults')}
)} {searchQuery && searchResults.length > 0 && (
    {searchResults.map((hit, idx) => { const tabLabelKey = TAB_LABEL_KEY[hit.tab]; const selected = idx === selectedResultIdx; return (
  • ); })}
)} {!searchQuery && <> {/* ── Audio ────────────────────────────────────────────────────────────── */} {activeTab === 'audio' && ( <> {/* Audio Output Device */} } defaultOpen >
{IS_MACOS ? (
{t('settings.audioOutputDeviceMacNotice')}
) : ( <>
{t('settings.audioOutputDeviceDesc')}
{ const device = val || null; setDeviceSwitching(true); try { await invoke('audio_set_device', { deviceName: device }); auth.setAudioOutputDevice(device); } catch { /* device open failed — don't persist */ } setDeviceSwitching(false); }} options={buildAudioDeviceSelectOptions( audioDevices, t('settings.audioOutputDeviceDefault'), osDefaultAudioDeviceId, t('settings.audioOutputDeviceOsDefaultNow'), auth.audioOutputDevice, t('settings.audioOutputDeviceNotInCurrentList'), )} />
)}
{/* Native Hi-Res Playback */} } >
{t('settings.hiResEnabled')}
{t('settings.hiResDesc')}
{/* Equalizer */} } >
{/* Replay Gain + Crossfade + Gapless */} } >
{/* Replay Gain */}
{t('settings.replayGain')}
{t('settings.replayGainDesc')}
{auth.replayGainEnabled && (
{t('settings.replayGainMode')}:
{auth.replayGainMode === 'auto' && (
{t('settings.replayGainAutoDesc')}
)}
)} {auth.replayGainEnabled && (
{t('settings.replayGainPreGain')} auth.setReplayGainPreGainDb(Number(e.target.value))} style={{ flex: 1, minWidth: 80, maxWidth: 160 }} /> {auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB
{t('settings.replayGainFallback')} auth.setReplayGainFallbackDb(Number(e.target.value))} style={{ flex: 1, minWidth: 80, maxWidth: 160 }} /> {auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB
)}
{/* Crossfade */}
{t('settings.crossfade')}
{auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')}
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
auth.setCrossfadeSecs(parseFloat(e.target.value))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="crossfade-secs-slider" /> {t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
)}
{/* Gapless */}
{t('settings.gapless')}
{auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')}
)} {/* ── Lyrics ───────────────────────────────────────────────────────────── */} {activeTab === 'lyrics' && ( <> } defaultOpen > } >
{(['classic', 'apple'] as const).map(style => { const key = style === 'classic' ? 'Classic' : 'Apple'; const other = style === 'classic' ? 'apple' : 'classic'; return (
{t(`settings.sidebarLyricsStyle${key}` as any)}
{t(`settings.sidebarLyricsStyle${key}Desc` as any)}
); })}
)} {/* ── Integrations ─────────────────────────────────────────────────────── */} {activeTab === 'integrations' && ( <>