diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ee5ad4b5..3b29943c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,5742 +1,2932 @@ import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; - import { version as appVersion } from '../../package.json'; - import changelogRaw from '../../CHANGELOG.md?raw'; - 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 - } 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 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 } from '../store/authStore'; - import { SeekbarPreview } from '../components/WaveformSeek'; - import { IS_LINUX } 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 { useHomeStore, HomeSectionId } from '../store/homeStore'; - import { useDragDrop, useDragSource } from '../contexts/DragDropContext'; - import { ALL_NAV_ITEMS } from '../config/navItems'; - import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; - import { switchActiveServer } from '../utils/switchActiveServer'; - import { open as openDialog } from '@tauri-apps/plugin-dialog'; - import { Trans, useTranslation } from 'react-i18next'; - import Equalizer from '../components/Equalizer'; - import StarRating from '../components/StarRating'; - import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity'; - - 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)', ], - }, - { - 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)', - ], - }, - { - 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)', ], - }, - { - 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)', - ], - }, - ] as const; - - const SPECIAL_THANKS = [ - { - github: 'netherguy4', - reason: 'Countless constructive feature ideas and thoughtful feedback', - }, - ] as const; - - type Tab = 'general' | 'server' | 'audio' | 'storage' | 'appearance' | 'input' | 'system'; - - function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit) => void; onCancel: () => void }) { - const { t } = useTranslation(); - const [form, setForm] = useState({ name: '', url: '', username: '', password: '' }); - const [showPass, setShowPass] = useState(false); - - const update = (k: keyof typeof form) => (e: React.ChangeEvent) => - setForm(f => ({ ...f, [k]: e.target.value })); - - return ( -
-

{t('settings.addServerTitle')}

-
- - -
-
- - -
-
-
- - -
-
- -
- - -
-
-
-
- - -
-
- ); - } - - 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 { state: routeState } = useLocation(); - const { t, i18n } = useTranslation(); - - const [activeTab, setActiveTab] = useState((routeState as { tab?: Tab } | null)?.tab ?? 'general'); - const [connStatus, setConnStatus] = useState>({}); - const [showAddForm, setShowAddForm] = useState(false); - 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 [contributorsOpen, setContributorsOpen] = useState(false); - - + const [fontPickerOpen, setFontPickerOpen] = useState(false); 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. - useEffect(() => { - if (activeTab !== 'audio') return; - refreshAudioDevices(); - }, [activeTab, refreshAudioDevices]); - - // Keep device list + "current system output" mark in sync when the backend reopens the stream. - useEffect(() => { - if (activeTab !== 'audio') 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' })); - } - }; - - const switchToServer = async (server: ServerProfile) => { - setConnStatus(s => ({ ...s, [server.id]: 'testing' })); - const ok = await switchActiveServer(server); - if (ok) { - setConnStatus(s => ({ ...s, [server.id]: 'ok' })); - navigate('/'); - } else { - setConnStatus(s => ({ ...s, [server.id]: 'error' })); - } - }; - - const deleteServer = (server: ServerProfile) => { - if (confirm(t('settings.confirmDeleteServer', { name: server.name || server.url }))) { - auth.removeServer(server.id); - } - }; - - const handleAddServer = async (data: Omit) => { - setShowAddForm(false); - 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 tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ - { id: 'general', label: t('settings.tabGeneral'), icon: }, - { id: 'server', label: t('settings.tabServer'), icon: }, - { id: 'audio', label: t('settings.tabAudio'), icon: }, - { id: 'storage', label: t('settings.tabStorage'), icon: }, - { id: 'appearance', label: t('settings.tabAppearance'), icon: }, - { id: 'input', label: t('settings.tabInput'), icon: }, - { id: 'system', label: t('settings.tabSystem'), icon: }, - ]; - - return ( -
-

{t('settings.title')}

- - {/* Tab navigation */} - - - {/* ── Audio ────────────────────────────────────────────────────────────── */} - {activeTab === 'audio' && ( - <> - {/* Audio Output Device */} -
-
- -

{t('settings.audioOutputDevice')}

-
-
-
- {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.hiResTitle')}

-
-
-
-
-
{t('settings.hiResEnabled')}
-
{t('settings.hiResDesc')}
-
- -
-
-
- - {/* Equalizer */} -
-
- -

{t('settings.eqTitle')}

-
-
- -
-
- - {/* Replay Gain + Crossfade + Gapless */} -
-
- -

{t('settings.playbackTitle')}

-
-
- {/* Replay Gain */} -
-
-
{t('settings.replayGain')}
-
{t('settings.replayGainDesc')}
-
- -
- {auth.replayGainEnabled && ( -
- {t('settings.replayGainMode')}: - - -
- )} - {auth.replayGainEnabled && ( -
-
- - {t('settings.replayGainPreGain')} - - auth.setReplayGainPreGainDb(Number(e.target.value))} - style={{ flex: 1, maxWidth: 160 }} - /> - - {auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB - -
-
- - {t('settings.replayGainFallback')} - - auth.setReplayGainFallbackDb(Number(e.target.value))} - style={{ flex: 1, 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={{ width: 120 }} - id="crossfade-secs-slider" - /> - - {t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })} - -
- )} - -
- - {/* Gapless */} -
-
-
- {t('settings.gapless')} -
-
- {auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')} -
-
- -
-
-
- - {/* Next Track Buffering */} -
-
- -

{t('settings.nextTrackBufferingTitle')}

-
-
-
- {t('settings.preloadHotCacheMutualExclusive')} -
- - {/* Preload mode */} -
-
-
{t('settings.preloadMode')}
-
{t('settings.preloadModeDesc')}
-
- -
- {auth.preloadMode !== 'off' && ( - <> -
- {(['balanced', 'early', 'custom'] as const).map(mode => ( - - ))} -
- {auth.preloadMode === 'custom' && ( -
- auth.setPreloadCustomSeconds(parseInt(e.target.value))} - style={{ width: 120 }} - /> - - {t('settings.preloadCustomSeconds', { n: auth.preloadCustomSeconds })} - -
- )} - - )} - -
- - {/* Hot Cache */} -
-
-
{t('settings.hotCacheTitle')}
-
{t('settings.hotCacheDisclaimer')}
-
- -
- - {auth.hotCacheEnabled && ( -
-
- - {auth.hotCacheDownloadDir && ( - - )} - -
- {auth.hotCacheDownloadDir && ( -
- {t('settings.hotCacheDirHint')} -
- )} - -
- -
-
- {t('settings.cacheUsedHot')} - {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'} -
-
- {t('settings.hotCacheTrackCount')} - {hotCacheTrackCount} -
-
- -
-
{t('settings.hotCacheMaxMb')}
-
- auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-max-mb-slider" /> - {snapHotCacheMb(auth.hotCacheMaxMb)} MB -
-
-
-
{t('settings.hotCacheDebounce')}
-
- auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ width: 140 }} id="hot-cache-debounce-slider" /> - - {Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0 - ? t('settings.hotCacheDebounceImmediate') - : t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })} - -
-
- -
- -
- )} - -
-
- - - )} - - {/* ── General ──────────────────────────────────────────────────────────── */} - {activeTab === 'general' && ( - <> - {/* App behaviour */} -
-
- -

{t('settings.behavior')}

-
-
-
-
-
{t('settings.showTrayIcon')}
-
{t('settings.showTrayIconDesc')}
-
- -
-
-
-
-
{t('settings.minimizeToTray')}
-
{t('settings.minimizeToTrayDesc')}
-
- -
- {IS_LINUX && !isTilingWm && ( - <> -
-
-
-
{t('settings.useCustomTitlebar')}
-
{t('settings.useCustomTitlebarDesc')}
-
- -
- - )} -
-
-
-
{t('settings.showArtistImages')}
-
{t('settings.showArtistImagesDesc')}
-
- -
-
-
-
-
{t('settings.discordRichPresence')}
-
{t('settings.discordRichPresenceDesc')}
-
- -
- {auth.discordRichPresence && ( - <> -
-
-
-
{t('settings.discordAppleCovers')}
-
{t('settings.discordAppleCoversDesc')}
-
- -
-
-
-
{t('settings.discordTemplates')}
-
{t('settings.discordTemplatesDesc')}
-
- - auth.setDiscordTemplateDetails(e.target.value)} - placeholder="{artist} - {title}" - /> -
-
- - auth.setDiscordTemplateState(e.target.value)} - placeholder="{album}" - /> -
-
- - auth.setDiscordTemplateLargeText(e.target.value)} - placeholder="{album}" - /> -
-
- - )} -
-
-
-
{t('settings.nowPlayingEnabled')}
-
{t('settings.nowPlayingEnabledDesc')}
-
- -
-
-
- - {/* Lyrics Sources */} - - - {/* Random Mix */} -
-
- -

{t('settings.randomMixTitle')}

-
-
-

- {t('settings.randomMixBlacklistDesc')} -

- -
{t('settings.randomMixBlacklistTitle')}
-
- {auth.customGenreBlacklist.length === 0 ? ( - {t('settings.randomMixBlacklistEmpty')} - ) : ( - auth.customGenreBlacklist.map(genre => ( - - {genre} - - - )) - )} -
- -
- setNewGenre(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter' && newGenre.trim()) { - const trimmed = newGenre.trim(); - if (!auth.customGenreBlacklist.includes(trimmed)) { - auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]); - } - setNewGenre(''); - } - }} - placeholder={t('settings.randomMixBlacklistPlaceholder')} - style={{ fontSize: 13 }} - /> - -
- -
- -
{t('settings.randomMixHardcodedTitle')}
-
- {AUDIOBOOK_GENRES_DISPLAY.map(genre => ( - - {genre} - - ))} -
-
-
- - {/* Ratings (single block under Random Mix) */} -
-
- -

{t('settings.ratingsSectionTitle')}

-
-
-
-
-
{t('settings.ratingsSkipStarTitle')}
-
{t('settings.ratingsSkipStarDesc')}
-
-
- {auth.skipStarOnManualSkipsEnabled && ( - <> - - auth.setSkipStarManualSkipThreshold(Number(e.target.value))} - style={{ width: 72, padding: '6px 10px', fontSize: 13 }} - aria-label={t('settings.ratingsSkipStarThresholdLabel')} - /> - - )} - -
-
- -
- -
-
-
{t('settings.ratingsMixFilterTitle')}
-
- {t('settings.ratingsMixFilterDesc', { - mix: t('sidebar.randomMix'), - albums: t('sidebar.randomAlbums'), - })} -
-
- -
- {auth.mixMinRatingFilterEnabled && ( - <> -
-
- {([ - { key: 'song', label: t('settings.ratingsMixMinSong'), value: auth.mixMinRatingSong, set: auth.setMixMinRatingSong }, - { key: 'album', label: t('settings.ratingsMixMinAlbum'), value: auth.mixMinRatingAlbum, set: auth.setMixMinRatingAlbum }, - { key: 'artist', label: t('settings.ratingsMixMinArtist'), value: auth.mixMinRatingArtist, set: auth.setMixMinRatingArtist }, - ] as const).map(row => ( -
- {row.label} - -
- ))} -
- - )} -
-
- - - - )} - - {/* ── Storage & Downloads ───────────────────────────────────────────────── */} - {activeTab === 'storage' && ( - <> - {/* Offline Library (In-App) — includes cache settings */} -
-
- -

{t('settings.offlineDirTitle')}

-
-
-
- {t('settings.offlineDirDesc')} -
-
- - {auth.offlineDownloadDir && ( - - )} - -
- {auth.offlineDownloadDir && ( -
- {t('settings.offlineDirHint')} -
- )} - -
- - {(imageCacheBytes !== null || offlineCacheBytes !== null) && ( -
-
- {t('settings.cacheUsedImages')} - {imageCacheBytes !== null ? formatBytes(imageCacheBytes) : '…'} -
-
- {t('settings.cacheUsedOffline')} - {offlineCacheBytes !== null ? formatBytes(offlineCacheBytes) : '…'} -
-
- )} - -
- {t('settings.cacheMaxLabel')} - { - const v = Number(e.target.value); - if (v >= 100) auth.setMaxCacheMb(v); - }} - style={{ width: 80, padding: '4px 8px', fontSize: 13 }} - id="cache-size-input" - /> - MB -
- {showClearConfirm ? ( -
-
{t('settings.cacheClearWarning')}
-
- - -
-
- ) : ( - - )} -
-
- - {/* ZIP Export & Archiving */} -
-
- -

{t('settings.downloadsTitle')}

-
-
-
- {t('settings.downloadsFolderDesc')} -
-
- - {auth.downloadFolder && ( - - )} - -
-
-
- - )} - - {/* ── Appearance ───────────────────────────────────────────────────────── */} - {activeTab === 'appearance' && ( - <> -
-
- -

{t('settings.language')}

-
-
-
- i18n.changeLanguage(v)} - options={[ - { value: 'en', label: t('settings.languageEn') }, - { value: 'de', label: t('settings.languageDe') }, - { value: 'es', label: t('settings.languageEs') }, - { value: 'fr', label: t('settings.languageFr') }, - { value: 'nl', label: t('settings.languageNl') }, - { value: 'nb', label: t('settings.languageNb') }, - { value: 'ru', label: t('settings.languageRu') }, - { value: 'zh', label: t('settings.languageZh') }, - ]} - /> -
-
-
- -
-
- -

{t('settings.theme')}

-
-
- {theme.enableThemeScheduler && ( -
- {t('settings.themeSchedulerActiveHint')} -
- )} - theme.setTheme(v as any)} /> -
-
- -
-
- -

{t('settings.themeSchedulerTitle')}

-
-
-
-
-
{t('settings.themeSchedulerEnable')}
-
{t('settings.themeSchedulerEnableSub')}
-
- -
- {theme.enableThemeScheduler && (() => { - const themeOptions = THEME_GROUPS.flatMap(g => - g.themes.map(th => ({ value: th.id, label: th.label, group: g.group })) - ); - const use12h = i18n.language === 'en'; - const hourOptions = Array.from({ length: 24 }, (_, i) => { - const value = String(i).padStart(2, '0'); - const label = use12h - ? `${i % 12 === 0 ? 12 : i % 12} ${i < 12 ? 'AM' : 'PM'}` - : value; - return { value, label }; - }); - const minuteOptions = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'].map(m => ({ value: m, label: m })); - const dayH = theme.timeDayStart.split(':')[0]; - const dayM = theme.timeDayStart.split(':')[1]; - const nightH = theme.timeNightStart.split(':')[0]; - const nightM = theme.timeNightStart.split(':')[1]; - return ( -
-
- - -
-
- -
- theme.setTimeDayStart(`${v}:${dayM}`)} options={hourOptions} /> - : - theme.setTimeDayStart(`${dayH}:${v}`)} options={minuteOptions} /> -
-
-
- - -
-
- -
- theme.setTimeNightStart(`${v}:${nightM}`)} options={hourOptions} /> - : - theme.setTimeNightStart(`${nightH}:${v}`)} options={minuteOptions} /> -
-
-
- ); - })()} -
-
- -
-
- -

{t('settings.visualOptionsTitle')}

-
-
-
-
-
{t('settings.coverArtBackground')}
-
{t('settings.coverArtBackgroundSub')}
-
- -
-
-
-
-
{t('settings.playlistCoverPhoto')}
-
{t('settings.playlistCoverPhotoSub')}
-
- -
-
-
-
-
{t('settings.showBitrate')}
-
{t('settings.showBitrateSub')}
-
- -
-
-
- -
-
- -

{t('settings.uiScaleTitle')}

-
-
- {/* TODO: UI scaling is being reworked — disabled until fixed */} -

- Interface scaling is currently being reworked and will be available in a future update. -

-
-
- {t('settings.uiScaleLabel')} - - {Math.round(fontStore.uiScale * 100)}% - -
- fontStore.setUiScale(parseFloat(e.target.value))} - className="ui-scale-slider" - /> -
- {[80, 90, 100, 110, 125, 150].map(p => { - const pct = ((p / 100) - 0.8) / (1.5 - 0.8) * 100; - const active = Math.round(fontStore.uiScale * 100) === p; - return ( - - ); - })} -
-
-
-
- -
-
- -

{t('settings.font')}

-
-
- -
- - {( - - [ - - { id: 'inter', label: 'Inter', stack: "'Inter', sans-serif" }, - - { id: 'outfit', label: 'Outfit', stack: "'Outfit', sans-serif" }, - - { id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans', sans-serif" }, - - { id: 'nunito', label: 'Nunito', stack: "'Nunito', sans-serif" }, - - { id: 'rubik', label: 'Rubik', stack: "'Rubik', sans-serif" }, - - { id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk', sans-serif" }, - - { id: 'figtree', label: 'Figtree', stack: "'Figtree', sans-serif" }, - - { id: 'manrope', label: 'Manrope', stack: "'Manrope', sans-serif" }, - - { id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans', sans-serif" }, - - { id: 'lexend', label: 'Lexend', stack: "'Lexend', sans-serif" }, - - ] as { id: FontId; label: string; stack: string }[] - - ).map(f => ( - - - - ))} - -
- -
- -
- - - -
- -
- - - -

{t('settings.fsPlayerSection')}

- -
- -
- -
- -
- -
{t('settings.fsShowArtistPortrait')}
- -
{t('settings.fsShowArtistPortraitDesc')}
- + + {fontPickerOpen && ( +
+ {( + [ + { id: 'inter', label: 'Inter', stack: "'Inter Variable', sans-serif" }, + { id: 'outfit', label: 'Outfit', stack: "'Outfit Variable', sans-serif" }, + { id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans Variable', sans-serif" }, + { id: 'nunito', label: 'Nunito', stack: "'Nunito Variable', sans-serif" }, + { id: 'rubik', label: 'Rubik', stack: "'Rubik Variable', sans-serif" }, + { id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk Variable', sans-serif" }, + { id: 'figtree', label: 'Figtree', stack: "'Figtree Variable', sans-serif" }, + { id: 'manrope', label: 'Manrope', stack: "'Manrope Variable', sans-serif" }, + { id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans Variable', sans-serif" }, + { id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" }, + { id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" }, + { id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" }, + ] as { id: FontId; label: string; stack: string }[] + ).map(f => ( + + ))}
- - - -
- - {auth.showFsArtistPortrait && ( - -
- -
- - {t('settings.fsPortraitDim')} - - {auth.fsPortraitDim}% - -
- - auth.setFsPortraitDim(parseInt(e.target.value, 10))} - - className="ui-scale-slider" - - /> - -
- )} -
-
- -
-
- - - -

{t('settings.seekbarStyle')}

- + +

{t('settings.fsPlayerSection')}

-
- -
- - {t('settings.seekbarStyleDesc')} - +
+
+
{t('settings.fsShowArtistPortrait')}
+
{t('settings.fsShowArtistPortraitDesc')}
+
+
- -
- - {(['waveform', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => ( - - auth.setSeekbarStyle(style)} - + {auth.showFsArtistPortrait && ( +
+
+ {t('settings.fsPortraitDim')} + {auth.fsPortraitDim}% +
+ auth.setFsPortraitDim(parseInt(e.target.value, 10))} + className="ui-scale-slider" /> - - ))} - -
- +
+ )}
-
- +
+
+ +

{t('settings.seekbarStyle')}

+
+
+
+ {t('settings.seekbarStyleDesc')} +
+
+ {(['waveform', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => ( + auth.setSeekbarStyle(style)} + /> + ))} +
+
+
- - )} - - {/* ── Input ────────────────────────────────────────────────────────────── */} - {activeTab === 'input' && ( - <> -
-
- -

{t('settings.tabInput')}

-
-
- -
-
- {([ - ['play-pause', t('settings.shortcutPlayPause')], - ['next', t('settings.shortcutNext')], - ['prev', t('settings.shortcutPrev')], - ['volume-up', t('settings.shortcutVolumeUp')], - ['volume-down', t('settings.shortcutVolumeDown')], - ['seek-forward', t('settings.shortcutSeekForward')], - ['seek-backward', t('settings.shortcutSeekBackward')], - ['toggle-queue', t('settings.shortcutToggleQueue')], - ['open-folder-browser', t('settings.shortcutOpenFolderBrowser', { folderBrowser: t('sidebar.folderBrowser') })], - ['fullscreen-player', t('settings.shortcutFullscreenPlayer')], - ['native-fullscreen', t('settings.shortcutNativeFullscreen')], - ] as [KeyAction, string][]).map(([action, label]) => { - const bound = kb.bindings[action]; - const isListening = listeningFor === action; - return ( -
- {label} -
- - {bound && !isListening && ( - - )} -
-
- ); - })} -
-
-
-
- -
-
- -

{t('settings.globalShortcutsTitle')}

-
-

- {t('settings.globalShortcutsNote')} -

-
- -
-
- {([ - ['play-pause', t('settings.shortcutPlayPause')], - ['next', t('settings.shortcutNext')], - ['prev', t('settings.shortcutPrev')], - ['volume-up', t('settings.shortcutVolumeUp')], - ['volume-down', t('settings.shortcutVolumeDown')], - ] as [GlobalAction, string][]).map(([action, label]) => { - const bound = gs.shortcuts[action] ?? null; - const isListening = listeningForGlobal === action; - return ( -
- {label} -
- - {bound && !isListening && ( - - )} -
-
- ); - })} -
-
-
-
- - )} - - {/* ── Server ───────────────────────────────────────────────────────────── */} - {activeTab === 'server' && ( - <> -
-
- -

{t('settings.servers')}

-
-
- {t('settings.serverCompatible')} -
- - {auth.servers.length === 0 && !showAddForm ? ( -
- {t('settings.noServers')} -
- ) : ( -
- {auth.servers.map(srv => { - const isActive = srv.id === auth.activeServerId; - const status = connStatus[srv.id]; - return ( -
-
-
-
- {srv.name || srv.url} - {isActive && ( - - {t('settings.serverActive')} - - )} -
-
- {srv.url.startsWith('https://') && ( - - )} - - {srv.url.replace(/^https?:\/\//, '')} - -
-
- - {srv.username} -
-
-
- {status === 'ok' && } - {status === 'error' && } - {status === 'testing' &&
} - - {!isActive && ( - - )} - -
-
- {showAudiomuseNavidromeServerSetting( - auth.subsonicServerIdentityByServer[srv.id], - auth.instantMixProbeByServer[srv.id], - ) && ( -
-
- -
-
- {t('settings.audiomuseTitle')} - - {t('settings.hotCacheAlphaBadge')} - - {!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && ( - - )} -
-
- { - e.preventDefault(); - void openUrl(AUDIOMUSE_NV_PLUGIN_URL); - }} - style={{ color: 'var(--accent)', textDecoration: 'underline' }} - /> - ), - }} - /> -
-
-
- -
- )} -
- ); - })} -
- )} - - {showAddForm ? ( - setShowAddForm(false)} /> - ) : ( - - )} -
- - {/* Last.fm */} -
-
- -

{t('settings.lfmTitle')}

-
-
- {auth.lastfmSessionKey ? ( - /* ── Connected state ── */ -
-
-
-
-
@{auth.lastfmUsername}
- {lfmUserInfo && ( -
- {t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })} - {t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })} -
- )} -
- -
-
-
-
{t('settings.scrobbleEnabled')}
-
{t('settings.scrobbleDesc')}
-
- -
-
- ) : lfmState === 'waiting' ? ( - /* ── Waiting for browser auth — auto-polling ── */ -
-
-
- {t('settings.lfmConnecting')} -
- -
- ) : ( - /* ── Not connected ── */ -
-

- {t('settings.lfmConnectDesc')} -

- {lfmState === 'error' && ( -

{lfmError}

- )} - -
- )} -
-
- -
- -
- - - )} - - {/* ── System ───────────────────────────────────────────────────────────── */} - {activeTab === 'system' && ( - <> - -
-
- -

{t('settings.aboutTitle')}

-
-
-
- Psysonic -
-
- Psysonic -
-
- {t('settings.aboutVersion')} {appVersion} -
-
-
- -

- {t('settings.aboutDesc')} -

- -
- -
-
- {t('settings.aboutLicense')} - {t('settings.aboutLicenseText')} -
-
- Stack - {t('settings.aboutBuiltWith')} -
-
- AI - {t('settings.aboutAiCredit')} -
-
- - - {contributorsOpen && ( -
- {CONTRIBUTORS.map(c => ( -
- {c.github} -
-
- - - v{c.since} - -
-
    - {c.contributions.map(item =>
  • {item}
  • )} -
-
-
- ))} -
- )} -
-
- {t('settings.aboutSpecialThanksLabel')} -
- {SPECIAL_THANKS.map(s => ( -
- {s.github} - - — {s.reason} -
- ))} -
-
-
- -
- -
-
-
- - - - )} -
- ); - } - - // ─── Changelog renderer ─────────────────────────────────────────────────────── - - function renderInline(text: string): React.ReactNode[] { - // Splits on **bold**, *italic*, `code` and renders each part. - const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g); - return parts.map((part, i) => { - if (part.startsWith('**') && part.endsWith('**')) - return {part.slice(2, -2)}; - if (part.startsWith('*') && part.endsWith('*')) - return {part.slice(1, -1)}; - if (part.startsWith('`') && part.endsWith('`')) - return {part.slice(1, -1)}; - return part; - }); - } - - function HomeCustomizer() { - const { t } = useTranslation(); - const { sections, toggleSection, reset } = useHomeStore(); - - const SECTION_LABELS: Record = { - hero: t('home.hero'), - recent: t('home.recent'), - discover: t('home.discover'), - discoverArtists: t('home.discoverArtists'), - recentlyPlayed: t('home.recentlyPlayed'), - starred: t('home.starred'), - mostPlayed: t('home.mostPlayed'), - }; - - return ( -
-
- -

{t('settings.homeCustomizerTitle')}

-
-
- -
- {sections.map(sec => ( -
- {SECTION_LABELS[sec.id]} - -
- ))} -
-
-
- ); - } - - function SidebarGripHandle({ idx, section, label }: { idx: number; section: 'library' | 'system'; label: string }) { - const { t } = useTranslation(); - const { onMouseDown } = useDragSource(() => ({ - data: JSON.stringify({ type: 'sidebar_reorder', index: idx, section }), - label, - })); - return ( - - - - ); - } - - // ── Lyrics Sources Customizer ────────────────────────────────────────────── - - const LYRICS_SOURCE_LABEL_KEYS: Record = { - server: 'settings.lyricsSourceServer', - lrclib: 'settings.lyricsSourceLrclib', - netease: 'settings.lyricsSourceNetease', - }; - - type LyricsDropTarget = { idx: number; before: boolean } | null; - - function LyricsSourceGripHandle({ idx, label }: { idx: number; label: string }) { - const { t } = useTranslation(); - const { onMouseDown } = useDragSource(() => ({ - data: JSON.stringify({ type: 'lyrics_source_reorder', index: idx }), - label, - })); - return ( - - - - ); - } - - function LyricsSourcesCustomizer() { - const { t } = useTranslation(); - const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources)); - const setLyricsSources = useAuthStore(s => s.setLyricsSources); - const { isDragging: isPsyDragging } = useDragDrop(); - const containerRef = useRef(null); - const [dropTarget, setDropTarget] = useState(null); - const dropTargetRef = useRef(null); - const sourcesRef = useRef(lyricsSources); - sourcesRef.current = lyricsSources; - - useEffect(() => { - if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); } - }, [isPsyDragging]); - - useEffect(() => { - const el = containerRef.current; - if (!el) 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 !== 'lyrics_source_reorder' || parsed.index == null) return; - - const fromIdx = parsed.index; - const target = dropTargetRef.current; - dropTargetRef.current = null; setDropTarget(null); - if (!target) return; - - const insertBefore = target.before ? target.idx : target.idx + 1; - if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; - - const next = [...sourcesRef.current]; - const [moved] = next.splice(fromIdx, 1); - next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); - setLyricsSources(next); - }; - el.addEventListener('psy-drop', onPsyDrop); - return () => el.removeEventListener('psy-drop', onPsyDrop); - }, [setLyricsSources]); - - const handleMouseMove = (e: React.MouseEvent) => { - if (!isPsyDragging || !containerRef.current) return; - const rows = containerRef.current.querySelectorAll('[data-lyrics-idx]'); - let target: LyricsDropTarget = null; - for (const row of rows) { - const rect = row.getBoundingClientRect(); - const idx = Number(row.dataset.lyricsIdx); - if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } - target = { idx, before: false }; - } - dropTargetRef.current = target; - setDropTarget(target); - }; - - const toggleSource = (id: LyricsSourceId) => { - setLyricsSources(sourcesRef.current.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s)); - }; - - return ( -
-
- -

{t('settings.lyricsSourcesTitle')}

-
-

- {t('settings.lyricsSourcesDesc')} -

-
- {lyricsSources.map((src, i) => { - const label = t(LYRICS_SOURCE_LABEL_KEYS[src.id]); - const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before; - const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before; - return ( -
- - {label} - -
- ); - })} -
-
- ); - } - - // ── Sidebar Customizer ────────────────────────────────────────────────────── - - type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null; - - function SidebarCustomizer() { - const { t } = useTranslation(); - const { items, setItems, toggleItem, reset } = useSidebarStore(); - const { isDragging: isPsyDragging } = useDragDrop(); - const containerRef = useRef(null); - const [dropTarget, setDropTarget] = useState(null); - const dropTargetRef = useRef(null); - const itemsRef = useRef(items); - itemsRef.current = items; + const randomNavMode = useAuthStore(s => s.randomNavMode); + const setRandomNavMode = useAuthStore(s => s.setRandomNavMode); - - - const libraryItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'library'); - + const libraryItems = items.filter(cfg => { + if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false; + if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false; + if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false; + return true; + }); const systemItems = items.filter(cfg => ALL_NAV_ITEMS[cfg.id]?.section === 'system'); - - useEffect(() => { - if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); } - }, [isPsyDragging]); - - useEffect(() => { - const el = containerRef.current; - if (!el) return; - const onPsyDrop = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (!detail?.data) return; - let parsed: { type?: string; index?: number; section?: string }; - try { parsed = JSON.parse(detail.data); } catch { return; } - if (parsed.type !== 'sidebar_reorder' || parsed.index == null || !parsed.section) return; - - const fromIdx = parsed.index; - const fromSection = parsed.section as 'library' | 'system'; - const target = dropTargetRef.current; - dropTargetRef.current = null; setDropTarget(null); - if (!target || target.section !== fromSection) return; - - const sectionItems = fromSection === 'library' ? [...libraryItems] : [...systemItems]; - const insertBefore = target.before ? target.idx : target.idx + 1; - if (insertBefore === fromIdx || insertBefore === fromIdx + 1) return; - - const [moved] = sectionItems.splice(fromIdx, 1); - sectionItems.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); - - - // Merge reordered section back into flat items array - + // Merge reordered section back into flat items array. + // Only update positions of the *visible* items (same filter as libraryItems/systemItems) + // so hidden entries like randomMix/randomAlbums are never overwritten with undefined. const all = [...itemsRef.current]; - + const visibleIds = new Set(sectionItems.map(c => c.id)); const positions = all.map((cfg, i) => ({ cfg, i })) - - .filter(({ cfg }) => ALL_NAV_ITEMS[cfg.id]?.section === fromSection) - + .filter(({ cfg }) => visibleIds.has(cfg.id)) .map(({ i }) => i); - positions.forEach((pos, i) => { all[pos] = sectionItems[i]; }); - setItems(all); - }; - el.addEventListener('psy-drop', onPsyDrop); - return () => el.removeEventListener('psy-drop', onPsyDrop); - }, [libraryItems, systemItems, setItems]); - - const handleMouseMove = (e: React.MouseEvent) => { - if (!isPsyDragging || !containerRef.current) return; - const rows = containerRef.current.querySelectorAll('[data-sidebar-idx]'); - let target: DropTarget = null; - for (const row of rows) { - const rect = row.getBoundingClientRect(); - const idx = Number(row.dataset.sidebarIdx); - const section = row.dataset.sidebarSection as 'library' | 'system'; - if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true, section }; break; } - target = { idx, before: false, section }; - } - dropTargetRef.current = target; - setDropTarget(target); - }; - - const renderRow = (cfg: SidebarItemConfig, localIdx: number, section: 'library' | 'system') => { - const meta = ALL_NAV_ITEMS[cfg.id]; - if (!meta) return null; - const Icon = meta.icon; - const isBefore = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && dropTarget.before; - const isAfter = isPsyDragging && dropTarget?.section === section && dropTarget.idx === localIdx && !dropTarget.before; - return ( -
- - - {t(meta.labelKey)} - -
- ); - }; - - return ( -
-
- -

{t('settings.sidebarTitle')}

- -
- -
- - {/* Library block */} - -
- -
{t('sidebar.library')}
- - {libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))} - -
- - {/* System block */} - -
- -
{t('sidebar.system')}
- - {systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))} - -
- - {t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')} - +
+
+
+
{t('settings.randomNavSplitTitle')}
+
{t('settings.randomNavSplitDesc')}
+
+ +
+
+
+ {/* Library block */} +
+
{t('sidebar.library')}
+ {libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))} +
+ {/* System block */} +
+
{t('sidebar.system')}
+ {systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))} +
+ {t('settings.sidebarFixed')}: {t('sidebar.nowPlaying')}, {t('sidebar.settings')}
-
-
-
- ); - } - - function BackupSection() { - const { t } = useTranslation(); - const [exporting, setExporting] = useState(false); - const [importing, setImporting] = useState(false); - - const handleExport = async () => { - setExporting(true); - try { - const path = await exportBackup(); - if (path) showToast(t('settings.backupSuccess'), 3000, 'info'); - } catch (e) { - console.error('Export failed', e); - showToast(t('settings.backupImportError'), 4000, 'error'); - } finally { - setExporting(false); - } - }; - - const handleImport = async () => { - if (!window.confirm(t('settings.backupImportConfirm'))) return; - setImporting(true); - try { - await importBackup(); - // importBackup reloads the page — this toast will briefly show before reload - showToast(t('settings.backupImportSuccess'), 3000, 'info'); - } catch (e) { - console.error('Import failed', e); - showToast(t('settings.backupImportError'), 4000, 'error'); - setImporting(false); - } - }; - - return ( -
-
- -

{t('settings.backupTitle')}

-
- - {/* Export */} -
-
-
-
{t('settings.backupExport')}
-
{t('settings.backupExportDesc')}
-
- -
-
- - {/* Import */} -
-
-
-
{t('settings.backupImport')}
-
{t('settings.backupImportDesc')}
-
- -
-
-
- ); - } - - function ChangelogSection() { - const { t } = useTranslation(); - const showChangelogOnUpdate = useAuthStore(s => s.showChangelogOnUpdate); - const setShowChangelogOnUpdate = useAuthStore(s => s.setShowChangelogOnUpdate); - - const versions = useMemo(() => { - const blocks = changelogRaw.split(/\n(?=## \[)/).filter(b => b.startsWith('## [')); - return blocks.map(block => { - const lines = block.split('\n'); - const headerLine = lines[0]; // e.g. "## [1.5.0] - 2026-03-18" - const versionMatch = headerLine.match(/## \[([^\]]+)\]/); - const dateMatch = headerLine.match(/- (\d{4}-\d{2}-\d{2})/); - const version = versionMatch?.[1] ?? ''; - const date = dateMatch?.[1] ?? ''; - - // Parse the rest into rendered lines - const body = lines.slice(1).join('\n').trim(); - return { version, date, body }; - }); - }, []); - - return ( -
-
- -

{t('settings.changelog')}

-
-
-
-
{t('settings.showChangelogOnUpdate')}
-
{t('settings.showChangelogOnUpdateDesc')}
-
- -
-
- {versions.slice(0, 3).map(({ version, date, body }) => ( -
- - v{version} - {date} - -
- {body.split('\n').map((line, i) => { - if (line.startsWith('### ')) { - return
{renderInline(line.slice(4))}
; - } - if (line.startsWith('#### ')) { - return
{renderInline(line.slice(5))}
; - } - if (line.startsWith('- ')) { - return
{renderInline(line.slice(2))}
; - } - if (line.trim() === '') return null; - return
{renderInline(line)}
; - })} -
-
- ))} -
-
- ); - } - diff --git a/src/store/authStore.ts b/src/store/authStore.ts index b4de1a47..408f1aa6 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -1,1224 +1,619 @@ import { create } from 'zustand'; - import { persist, createJSONStorage } from 'zustand/middleware'; - import { invoke } from '@tauri-apps/api/core'; - import type { EntityRatingSupportLevel } from '../api/subsonic'; - import { - isNavidromeAudiomuseSoftwareEligible, - type InstantMixProbeResult, - type SubsonicServerIdentity, - } from '../utils/subsonicServerIdentity'; - import { usePlayerStore } from './playerStore'; - - export interface ServerProfile { - id: string; - name: string; - url: string; - username: string; - password: string; - } - - export type SeekbarStyle = 'waveform' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape'; - - export type LyricsSourceId = 'server' | 'lrclib' | 'netease'; - export interface LyricsSourceConfig { id: LyricsSourceId; enabled: boolean; } - - const DEFAULT_LYRICS_SOURCES: LyricsSourceConfig[] = [ - { id: 'server', enabled: true }, - { id: 'lrclib', enabled: true }, - { id: 'netease', enabled: false }, - ]; - - interface AuthState { - // Multi-server - servers: ServerProfile[]; - activeServerId: string | null; - - // Last.fm (global) - lastfmApiKey: string; - lastfmApiSecret: string; - lastfmSessionKey: string; - lastfmUsername: string; - - // Settings (global) - scrobblingEnabled: boolean; - maxCacheMb: number; - downloadFolder: string; - offlineDownloadDir: string; - excludeAudiobooks: boolean; - customGenreBlacklist: string[]; - replayGainEnabled: boolean; - replayGainMode: 'track' | 'album'; - replayGainPreGainDb: number; // added to RG gain for tagged files (0…+6 dB) - replayGainFallbackDb: number; // gain for untagged files / radio (-6…0 dB) - crossfadeEnabled: boolean; - crossfadeSecs: number; - gaplessEnabled: boolean; - preloadMode: 'off' | 'balanced' | 'early' | 'custom'; - preloadCustomSeconds: number; - infiniteQueueEnabled: boolean; - showArtistImages: boolean; - showTrayIcon: boolean; - minimizeToTray: boolean; - discordRichPresence: boolean; - enableAppleMusicCoversDiscord: boolean; - discordTemplateDetails: string; - discordTemplateState: string; - discordTemplateLargeText: string; - useCustomTitlebar: boolean; - nowPlayingEnabled: boolean; - lyricsServerFirst: boolean; - enableNeteaselyrics: boolean; - lyricsSources: LyricsSourceConfig[]; - showFullscreenLyrics: boolean; - showFsArtistPortrait: boolean; - /** Portrait dimming 0–100 (percent), applied as CSS rgba alpha */ - fsPortraitDim: number; - showChangelogOnUpdate: boolean; - lastSeenChangelogVersion: string; - - seekbarStyle: SeekbarStyle; - - /** Alpha: native hi-res sample rate output (disabled = safe 44.1 kHz mode) */ - enableHiRes: boolean; - /** Selected audio output device name. null = system default. */ - audioOutputDevice: string | null; - - /** Alpha: ephemeral queue prefetch cache on disk */ - hotCacheEnabled: boolean; - hotCacheMaxMb: number; - hotCacheDebounceSec: number; - /** Parent directory; actual cache is `/psysonic-hot-cache/`. Empty = app data. */ - hotCacheDownloadDir: string; - - /** After this many manual skips of the same track, set track rating to 1 if still unrated (below 1 star). */ - skipStarOnManualSkipsEnabled: boolean; - /** Manual skips per track before applying rating 1 (when enabled). */ - skipStarManualSkipThreshold: number; - /** - * Manual Next-count per track for skip→1★. Key = `${serverId}\\u001f${trackId}` - * (empty serverId when none). Persisted; cleared when the track finishes naturally or when threshold is reached. - */ - skipStarManualSkipCountsByKey: Record; - /** Increment skip count for current server + track; clears stored count when threshold reached. */ - recordSkipStarManualAdvance: (trackId: string) => { crossedThreshold: boolean } | null; - /** Drop persisted skip count for this track on the active server (e.g. natural playback end). */ - clearSkipStarManualCountForTrack: (trackId: string) => void; - - /** Random mixes, random albums, home hero: drop non‑zero ratings at or below per‑axis thresholds (0 = unrated, kept). */ - mixMinRatingFilterEnabled: boolean; - /** 0 = ignore; 1–3 = cutoff (UI); exclude track rating r when 0 < r ≤ cutoff. */ - mixMinRatingSong: number; - /** 0 = ignore; album entity rating from payload or `getAlbum` when missing. */ - mixMinRatingAlbum: number; - /** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */ - mixMinRatingArtist: number; - - /** Subsonic music folders for the active server (not persisted; refetched on login / server change). */ - musicFolders: Array<{ id: string; name: string }>; - /** - * Per server: `all` = no musicFolderId param; otherwise a single folder id. - * Only one library or all — no multi-folder merge. - */ - musicLibraryFilterByServer: Record; - /** Bumps when `setMusicLibraryFilter` runs so pages refetch catalog data. */ - musicLibraryFilterVersion: number; - - /** - * Per server: whether `setRating` is assumed to work for album/artist ids (OpenSubsonic-style). - * Absent key = not probed yet (`unknown` in UI). - */ - entityRatingSupportByServer: Record; - setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void; - - /** - * Per server: Navidrome has the AudioMuse-AI plugin — use `getSimilarSongs` (Instant Mix) and - * `getArtistInfo2` similar artists instead of Last.fm for discovery on this server. - */ - audiomuseNavidromeByServer: Record; - setAudiomuseNavidromeEnabled: (serverId: string, enabled: boolean) => void; - - /** From `ping` — used to show the AudioMuse toggle only on Navidrome ≥ 0.60. */ - subsonicServerIdentityByServer: Record; - setSubsonicServerIdentity: (serverId: string, identity: SubsonicServerIdentity) => void; - - /** Instant Mix / similar path failed while this server had AudioMuse enabled (cleared on success or toggle off). */ - audiomuseNavidromeIssueByServer: Record; - setAudiomuseNavidromeIssue: (serverId: string, hasIssue: boolean) => void; - - /** - * `getSimilarSongs` probe per server (after ping). `empty` hides the AudioMuse row; re-run by testing connection. - */ - instantMixProbeByServer: Record; - setInstantMixProbe: (serverId: string, result: InstantMixProbeResult) => void; - - // Status - isLoggedIn: boolean; - isConnecting: boolean; - connectionError: string | null; - lastfmSessionError: boolean; - - // Actions - addServer: (profile: Omit) => string; - updateServer: (id: string, data: Partial>) => void; - removeServer: (id: string) => void; - setActiveServer: (id: string) => void; - setLoggedIn: (v: boolean) => void; - setConnecting: (v: boolean) => void; - setConnectionError: (e: string | null) => void; - setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void; - connectLastfm: (sessionKey: string, username: string) => void; - disconnectLastfm: () => void; - setLastfmSessionError: (v: boolean) => void; - setScrobblingEnabled: (v: boolean) => void; - setMaxCacheMb: (v: number) => void; - setDownloadFolder: (v: string) => void; - setOfflineDownloadDir: (v: string) => void; - setExcludeAudiobooks: (v: boolean) => void; - setCustomGenreBlacklist: (v: string[]) => void; - setReplayGainEnabled: (v: boolean) => void; - setReplayGainMode: (v: 'track' | 'album') => void; - setReplayGainPreGainDb: (v: number) => void; - setReplayGainFallbackDb: (v: number) => void; - setCrossfadeEnabled: (v: boolean) => void; - setCrossfadeSecs: (v: number) => void; - setGaplessEnabled: (v: boolean) => void; - setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => void; - setPreloadCustomSeconds: (v: number) => void; - setInfiniteQueueEnabled: (v: boolean) => void; - setShowArtistImages: (v: boolean) => void; - setShowTrayIcon: (v: boolean) => void; - setMinimizeToTray: (v: boolean) => void; - setDiscordRichPresence: (v: boolean) => void; - setEnableAppleMusicCoversDiscord: (v: boolean) => void; - setDiscordTemplateDetails: (v: string) => void; - setDiscordTemplateState: (v: string) => void; - setDiscordTemplateLargeText: (v: string) => void; - setUseCustomTitlebar: (v: boolean) => void; - setNowPlayingEnabled: (v: boolean) => void; - setLyricsServerFirst: (v: boolean) => void; - setEnableNeteaselyrics: (v: boolean) => void; - setLyricsSources: (sources: LyricsSourceConfig[]) => void; - setShowFullscreenLyrics: (v: boolean) => void; - setShowFsArtistPortrait: (v: boolean) => void; - setFsPortraitDim: (v: number) => void; - setShowChangelogOnUpdate: (v: boolean) => void; - setLastSeenChangelogVersion: (v: string) => void; - setSeekbarStyle: (v: SeekbarStyle) => void; - setEnableHiRes: (v: boolean) => void; - setAudioOutputDevice: (v: string | null) => void; - setHotCacheEnabled: (v: boolean) => void; - setHotCacheMaxMb: (v: number) => void; - setHotCacheDebounceSec: (v: number) => void; - setHotCacheDownloadDir: (v: string) => void; - setSkipStarOnManualSkipsEnabled: (v: boolean) => void; - setSkipStarManualSkipThreshold: (v: number) => void; - setMixMinRatingFilterEnabled: (v: boolean) => void; - setMixMinRatingSong: (v: number) => void; - setMixMinRatingAlbum: (v: number) => void; - setMixMinRatingArtist: (v: number) => void; - setMusicFolders: (folders: Array<{ id: string; name: string }>) => void; - setMusicLibraryFilter: (folderId: 'all' | string) => void; + /** Navigation style for Mix pages: single hub ('hub') or separate sidebar entries ('separate'). */ + randomNavMode: 'hub' | 'separate'; + setRandomNavMode: (v: 'hub' | 'separate') => void; + logout: () => void; - - // Derived - getBaseUrl: () => string; - getActiveServer: () => ServerProfile | undefined; - } - - function generateId(): string { - return Date.now().toString(36) + Math.random().toString(36).slice(2); - } - - /** Upper bound for mix min-rating thresholds (UI shows five stars, only 1…this many are selectable). */ - export const MIX_MIN_RATING_FILTER_MAX_STARS = 3; - - function clampMixFilterMinStars(v: number): number { - if (!Number.isFinite(v)) return 0; - return Math.max(0, Math.min(MIX_MIN_RATING_FILTER_MAX_STARS, Math.round(v))); - } - - function clampSkipStarThreshold(v: number): number { - if (!Number.isFinite(v)) return 3; - return Math.max(1, Math.min(99, Math.round(v))); - } - - function skipStarCountStorageKey(serverId: string | null | undefined, trackId: string): string { - return `${serverId ?? ''}\u001f${trackId}`; - } - - function sanitizeSkipStarCounts(raw: unknown): Record { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; - const next: Record = {}; - for (const [k, v] of Object.entries(raw as Record)) { - const n = Number(v); - if (Number.isFinite(n) && n > 0) next[k] = Math.min(Math.floor(n), 1_000_000); - } - return next; - } - - export const useAuthStore = create()( - persist( - (set, get) => ({ - servers: [], - activeServerId: null, - lastfmApiKey: '', - lastfmApiSecret: '', - lastfmSessionKey: '', - lastfmUsername: '', - scrobblingEnabled: true, - maxCacheMb: 500, - downloadFolder: '', - offlineDownloadDir: '', - excludeAudiobooks: false, - customGenreBlacklist: [], - replayGainEnabled: false, - replayGainMode: 'track', - replayGainPreGainDb: 0, - replayGainFallbackDb: 0, - crossfadeEnabled: false, - crossfadeSecs: 3, - gaplessEnabled: false, - preloadMode: 'balanced', - preloadCustomSeconds: 30, - infiniteQueueEnabled: false, - showArtistImages: false, - showTrayIcon: true, - minimizeToTray: false, - discordRichPresence: false, - enableAppleMusicCoversDiscord: false, - discordTemplateDetails: '{artist} - {title}', - discordTemplateState: '{album}', - discordTemplateLargeText: '{album}', - useCustomTitlebar: false, - nowPlayingEnabled: false, - lyricsServerFirst: true, - enableNeteaselyrics: false, - lyricsSources: DEFAULT_LYRICS_SOURCES, - showFullscreenLyrics: true, - showFsArtistPortrait: true, - fsPortraitDim: 28, - showChangelogOnUpdate: true, - lastSeenChangelogVersion: '', - seekbarStyle: 'waveform', - enableHiRes: false, - audioOutputDevice: null, - hotCacheEnabled: false, - hotCacheMaxMb: 256, - hotCacheDebounceSec: 30, - hotCacheDownloadDir: '', - skipStarOnManualSkipsEnabled: false, - skipStarManualSkipThreshold: 3, - skipStarManualSkipCountsByKey: {}, - mixMinRatingFilterEnabled: false, - mixMinRatingSong: 0, - mixMinRatingAlbum: 0, - mixMinRatingArtist: 0, - + randomNavMode: 'hub', musicFolders: [], - musicLibraryFilterByServer: {}, - musicLibraryFilterVersion: 0, - entityRatingSupportByServer: {}, - audiomuseNavidromeByServer: {}, - subsonicServerIdentityByServer: {}, - audiomuseNavidromeIssueByServer: {}, - instantMixProbeByServer: {}, - isLoggedIn: false, - isConnecting: false, - connectionError: null, - lastfmSessionError: false, - - addServer: (profile) => { - const id = generateId(); - set(s => ({ servers: [...s.servers, { ...profile, id }] })); - return id; - }, - - updateServer: (id, data) => { - set(s => ({ - servers: s.servers.map(srv => srv.id === id ? { ...srv, ...data } : srv), - })); - }, - - removeServer: (id) => { - set(s => { - const newServers = s.servers.filter(srv => srv.id !== id); - const switchedAway = s.activeServerId === id; - const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer; - const { [id]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer; - const { [id]: _idn, ...identityRest } = s.subsonicServerIdentityByServer; - const { [id]: _iss, ...issueRest } = s.audiomuseNavidromeIssueByServer; - const { [id]: _pr, ...probeRest } = s.instantMixProbeByServer; - return { - servers: newServers, - activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId, - isLoggedIn: switchedAway ? false : s.isLoggedIn, - entityRatingSupportByServer: entityRatingRest, - audiomuseNavidromeByServer: audiomuseRest, - subsonicServerIdentityByServer: identityRest, - audiomuseNavidromeIssueByServer: issueRest, - instantMixProbeByServer: probeRest, - }; - }); - }, - - setActiveServer: (id) => set({ activeServerId: id, musicFolders: [] }), - - setLoggedIn: (v) => set({ isLoggedIn: v }), - setConnecting: (v) => set({ isConnecting: v }), - setConnectionError: (e) => set({ connectionError: e }), - - setLastfm: (apiKey, apiSecret, sessionKey, username) => - set({ lastfmApiKey: apiKey, lastfmApiSecret: apiSecret, lastfmSessionKey: sessionKey, lastfmUsername: username }), - - connectLastfm: (sessionKey, username) => - set({ lastfmSessionKey: sessionKey, lastfmUsername: username }), - - disconnectLastfm: () => - set({ lastfmSessionKey: '', lastfmUsername: '', lastfmSessionError: false }), - - setLastfmSessionError: (v) => set({ lastfmSessionError: v }), - - setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }), - setMaxCacheMb: (v) => set({ maxCacheMb: v }), - setDownloadFolder: (v) => set({ downloadFolder: v }), - setOfflineDownloadDir: (v) => set({ offlineDownloadDir: v }), - setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }), - setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }), - setReplayGainEnabled: (v) => { - set({ replayGainEnabled: v }); - usePlayerStore.getState().updateReplayGainForCurrentTrack(); - }, - setReplayGainMode: (v) => { - set({ replayGainMode: v }); - usePlayerStore.getState().updateReplayGainForCurrentTrack(); - }, - setReplayGainPreGainDb: (v) => { - set({ replayGainPreGainDb: v }); - usePlayerStore.getState().updateReplayGainForCurrentTrack(); - }, - setReplayGainFallbackDb: (v) => { - set({ replayGainFallbackDb: v }); - usePlayerStore.getState().updateReplayGainForCurrentTrack(); - }, - setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }), - setCrossfadeSecs: (v) => set({ crossfadeSecs: v }), - setGaplessEnabled: (v) => set({ gaplessEnabled: v }), - setPreloadMode: (v: 'off' | 'balanced' | 'early' | 'custom') => set({ preloadMode: v }), - setPreloadCustomSeconds: (v: number) => set({ preloadCustomSeconds: v }), - setInfiniteQueueEnabled: (v) => set({ infiniteQueueEnabled: v }), - setShowArtistImages: (v) => set({ showArtistImages: v }), - setShowTrayIcon: (v) => set({ showTrayIcon: v }), - setMinimizeToTray: (v) => set({ minimizeToTray: v }), - setDiscordRichPresence: (v) => set({ discordRichPresence: v }), - setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }), - setDiscordTemplateDetails: (v) => set({ discordTemplateDetails: v }), - setDiscordTemplateState: (v) => set({ discordTemplateState: v }), - setDiscordTemplateLargeText: (v) => set({ discordTemplateLargeText: v }), - setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }), - setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }), - setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }), - setEnableNeteaselyrics: (v: boolean) => set({ enableNeteaselyrics: v }), - setLyricsSources: (sources) => set({ lyricsSources: sources }), - setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }), - setShowFsArtistPortrait: (v: boolean) => set({ showFsArtistPortrait: v }), - setFsPortraitDim: (v: number) => set({ fsPortraitDim: v }), - setShowChangelogOnUpdate: (v) => set({ showChangelogOnUpdate: v }), - setLastSeenChangelogVersion: (v) => set({ lastSeenChangelogVersion: v }), - - setSeekbarStyle: (v) => set({ seekbarStyle: v }), - setEnableHiRes: (v) => set({ enableHiRes: v }), - setAudioOutputDevice: (v) => set({ audioOutputDevice: v }), - setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }), - setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }), - setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }), - setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }), - - setSkipStarOnManualSkipsEnabled: (v) => - set({ - skipStarOnManualSkipsEnabled: v, - ...(v ? {} : { skipStarManualSkipCountsByKey: {} }), - }), - setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }), - - recordSkipStarManualAdvance: (trackId: string) => { - const s = get(); - if (!s.skipStarOnManualSkipsEnabled || s.skipStarManualSkipThreshold < 1) return null; - const key = skipStarCountStorageKey(s.activeServerId, trackId); - const prev = s.skipStarManualSkipCountsByKey[key] ?? 0; - const threshold = s.skipStarManualSkipThreshold; - const next = prev + 1; - if (next >= threshold) { - const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey; - set({ skipStarManualSkipCountsByKey: rest }); - return { crossedThreshold: true }; - } - set({ - skipStarManualSkipCountsByKey: { ...s.skipStarManualSkipCountsByKey, [key]: next }, - }); - return { crossedThreshold: false }; - }, - - clearSkipStarManualCountForTrack: (trackId: string) => { - const s = get(); - const key = skipStarCountStorageKey(s.activeServerId, trackId); - if (s.skipStarManualSkipCountsByKey[key] === undefined) return; - const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey; - set({ skipStarManualSkipCountsByKey: rest }); - }, - - setMixMinRatingFilterEnabled: (v) => set({ mixMinRatingFilterEnabled: v }), - setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }), - setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }), - setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }), - - + setRandomNavMode: (v) => set({ randomNavMode: v }), setMusicFolders: (folders) => { - const sid = get().activeServerId; - set(s => { - const f = sid ? s.musicLibraryFilterByServer[sid] : undefined; - const invalidFilter = f && f !== 'all' && !folders.some(x => x.id === f); - return { - musicFolders: folders, - ...(sid && invalidFilter - ? { musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: 'all' } } - : {}), - }; - }); - }, - - setMusicLibraryFilter: (folderId) => { - const sid = get().activeServerId; - if (!sid) return; - set(s => ({ - musicLibraryFilterByServer: { ...s.musicLibraryFilterByServer, [sid]: folderId }, - musicLibraryFilterVersion: s.musicLibraryFilterVersion + 1, - })); - }, - - setEntityRatingSupport: (serverId, level) => - set(s => ({ - entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level }, - })), - - setAudiomuseNavidromeEnabled: (serverId, enabled) => - set(s => { - const audiomuseNavidromeByServer = enabled - ? { ...s.audiomuseNavidromeByServer, [serverId]: true } - : (() => { - const { [serverId]: _removed, ...rest } = s.audiomuseNavidromeByServer; - return rest; - })(); - const { [serverId]: _issueRm, ...issueRest } = s.audiomuseNavidromeIssueByServer; - return { audiomuseNavidromeByServer, audiomuseNavidromeIssueByServer: issueRest }; - }), - - setSubsonicServerIdentity: (serverId, identity) => - set(s => { - const subsonicServerIdentityByServer = { ...s.subsonicServerIdentityByServer, [serverId]: { ...identity } }; - if (!isNavidromeAudiomuseSoftwareEligible(identity)) { - const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer; - const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer; - const { [serverId]: _p, ...probeRest } = s.instantMixProbeByServer; - return { - subsonicServerIdentityByServer, - audiomuseNavidromeByServer: audiomuseRest, - audiomuseNavidromeIssueByServer: issueRest, - instantMixProbeByServer: probeRest, - }; - } - return { subsonicServerIdentityByServer }; - }), - - setInstantMixProbe: (serverId, result) => - set(s => { - const instantMixProbeByServer = { ...s.instantMixProbeByServer, [serverId]: result }; - if (result === 'empty') { - const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer; - const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer; - return { - instantMixProbeByServer, - audiomuseNavidromeByServer: audiomuseRest, - audiomuseNavidromeIssueByServer: issueRest, - }; - } - return { instantMixProbeByServer }; - }), - - setAudiomuseNavidromeIssue: (serverId, hasIssue) => - set(s => - hasIssue - ? { audiomuseNavidromeIssueByServer: { ...s.audiomuseNavidromeIssueByServer, [serverId]: true } } - : (() => { - const { [serverId]: _rm, ...rest } = s.audiomuseNavidromeIssueByServer; - return { audiomuseNavidromeIssueByServer: rest }; - })(), - ), - - logout: () => set({ isLoggedIn: false, musicFolders: [] }), - - getBaseUrl: () => { - const s = get(); - const server = s.servers.find(srv => srv.id === s.activeServerId); - if (!server?.url) return ''; - const base = server.url.startsWith('http') ? server.url : `http://${server.url}`; - return base.replace(/\/$/, ''); - }, - - getActiveServer: () => { - const s = get(); - return s.servers.find(srv => srv.id === s.activeServerId); - }, - }), - { - name: 'psysonic-auth', - storage: createJSONStorage(() => localStorage), - partialize: state => { - const { musicFolders: _mf, musicLibraryFilterVersion: _fv, ...rest } = state; - return rest; - }, - onRehydrateStorage: () => (state, error) => { - if (error || !state) return; - // If both hot cache and preload were enabled before mutual exclusion was enforced, reset both. - const conflictingLegacyState = - state.hotCacheEnabled && state.preloadMode !== 'off' - ? { hotCacheEnabled: false, preloadMode: 'off' as const } - : {}; - - // Migrate lyricsServerFirst + enableNeteaselyrics → lyricsSources (one-time). - let lyricsSourcesMigrated: { lyricsSources?: LyricsSourceConfig[] } = {}; - try { - const raw = JSON.parse(localStorage.getItem('psysonic-auth') ?? '{}') as { state?: Record }; - if (!raw?.state?.lyricsSources) { - const serverFirst = (raw?.state?.lyricsServerFirst as boolean | undefined) ?? true; - const neteaseOn = (raw?.state?.enableNeteaselyrics as boolean | undefined) ?? false; - const migrated: LyricsSourceConfig[] = serverFirst - ? [{ id: 'server', enabled: true }, { id: 'lrclib', enabled: true }, { id: 'netease', enabled: neteaseOn }] - : [{ id: 'lrclib', enabled: true }, { id: 'server', enabled: true }, { id: 'netease', enabled: neteaseOn }]; - lyricsSourcesMigrated = { lyricsSources: migrated }; - } - } catch { /* ignore */ } - - useAuthStore.setState({ - mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number), - mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number), - mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number), - skipStarManualSkipCountsByKey: sanitizeSkipStarCounts( - (state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey, - ), - ...conflictingLegacyState, - ...lyricsSourcesMigrated, - }); - }, - } - ) - ); -