diff --git a/src/components/settings/ArtistLayoutCustomizer.tsx b/src/components/settings/ArtistLayoutCustomizer.tsx new file mode 100644 index 00000000..b21f992c --- /dev/null +++ b/src/components/settings/ArtistLayoutCustomizer.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GripVertical } from 'lucide-react'; +import { useDragDrop, useDragSource } from '../../contexts/DragDropContext'; +import { useArtistLayoutStore, type ArtistSectionConfig, type ArtistSectionId } from '../../store/artistLayoutStore'; + +const ARTIST_SECTION_LABEL_KEYS: Record = { + bio: 'settings.artistLayoutBio', + topTracks: 'settings.artistLayoutTopTracks', + similar: 'settings.artistLayoutSimilar', + albums: 'settings.artistLayoutAlbums', + featured: 'settings.artistLayoutFeatured', +}; + +type ArtistDropTarget = { idx: number; before: boolean } | null; + +function ArtistSectionGripHandle({ idx, label }: { idx: number; label: string }) { + const { t } = useTranslation(); + const { onMouseDown } = useDragSource(() => ({ + data: JSON.stringify({ type: 'artist_section_reorder', index: idx }), + label, + })); + return ( + + + + ); +} + +export function ArtistLayoutCustomizer() { + const { t } = useTranslation(); + const sections = useArtistLayoutStore(s => s.sections); + const setSections = useArtistLayoutStore(s => s.setSections); + const toggleSection = useArtistLayoutStore(s => s.toggleSection); + const { isDragging: isPsyDragging } = useDragDrop(); + const [containerEl, setContainerEl] = useState(null); + const [dropTarget, setDropTarget] = useState(null); + const dropTargetRef = useRef(null); + const sectionsRef = useRef(sections); + sectionsRef.current = sections; + + useEffect(() => { + if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); } + }, [isPsyDragging]); + + useEffect(() => { + if (!containerEl) 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 !== 'artist_section_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 = [...sectionsRef.current]; + const [moved] = next.splice(fromIdx, 1); + next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); + setSections(next); + }; + containerEl.addEventListener('psy-drop', onPsyDrop); + return () => containerEl.removeEventListener('psy-drop', onPsyDrop); + }, [containerEl, setSections]); + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isPsyDragging || !containerEl) return; + const rows = containerEl.querySelectorAll('[data-artist-idx]'); + let target: ArtistDropTarget = null; + for (const row of rows) { + const rect = row.getBoundingClientRect(); + const idx = Number(row.dataset.artistIdx); + if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } + target = { idx, before: false }; + } + dropTargetRef.current = target; + setDropTarget(target); + }; + + return ( + <> +

+ {t('settings.artistLayoutDesc')} +

+
+ {sections.map((section: ArtistSectionConfig, i) => { + const label = t(ARTIST_SECTION_LABEL_KEYS[section.id]); + const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before; + const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before; + return ( +
+ + {label} + +
+ ); + })} +
+ + ); +} diff --git a/src/components/settings/BackupSection.tsx b/src/components/settings/BackupSection.tsx new file mode 100644 index 00000000..4239907c --- /dev/null +++ b/src/components/settings/BackupSection.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Download, HardDrive, Upload } from 'lucide-react'; +import { exportBackup, importBackup } from '../../utils/backup'; +import { showToast } from '../../utils/toast'; + +export 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')}
+
+ +
+
+
+ ); +} diff --git a/src/components/settings/HomeCustomizer.tsx b/src/components/settings/HomeCustomizer.tsx new file mode 100644 index 00000000..86169bbf --- /dev/null +++ b/src/components/settings/HomeCustomizer.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { useHomeStore, HomeSectionId } from '../../store/homeStore'; + +export function HomeCustomizer() { + const { t } = useTranslation(); + const { sections, toggleSection } = useHomeStore(); + + const SECTION_LABELS: Record = { + hero: t('home.hero'), + recent: t('home.recent'), + discover: t('home.discover'), + becauseYouLike: t('home.becauseYouLike'), + discoverSongs: t('home.discoverSongs'), + discoverArtists: t('home.discoverArtists'), + recentlyPlayed: t('home.recentlyPlayed'), + starred: t('home.starred'), + mostPlayed: t('home.mostPlayed'), + losslessAlbums: t('home.losslessAlbums'), + }; + + return ( +
+ {sections.map(sec => ( +
+ {SECTION_LABELS[sec.id]} + +
+ ))} +
+ ); +} diff --git a/src/components/settings/LyricsSourcesCustomizer.tsx b/src/components/settings/LyricsSourcesCustomizer.tsx new file mode 100644 index 00000000..1d69d432 --- /dev/null +++ b/src/components/settings/LyricsSourcesCustomizer.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GripVertical, Music2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; +import { useDragDrop, useDragSource } from '../../contexts/DragDropContext'; +import { useAuthStore } from '../../store/authStore'; +import type { LyricsSourceId } from '../../store/authStoreTypes'; + +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 ( + + + + ); +} + +export function LyricsSourcesCustomizer() { + const { t } = useTranslation(); + const lyricsSources = useAuthStore(useShallow(s => s.lyricsSources)); + const setLyricsSources = useAuthStore(s => s.setLyricsSources); + const lyricsMode = useAuthStore(s => s.lyricsMode); + const setLyricsMode = useAuthStore(s => s.setLyricsMode); + const lyricsStaticOnly = useAuthStore(s => s.lyricsStaticOnly); + const setLyricsStaticOnly = useAuthStore(s => s.setLyricsStaticOnly); + const { isDragging: isPsyDragging } = useDragDrop(); + // useState (not useRef) so the listener-effect re-runs when the container + // gets unmounted/remounted by the {lyricsMode === 'standard'} wrapper. + const [containerEl, setContainerEl] = useState(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(() => { + if (!containerEl) 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); + }; + containerEl.addEventListener('psy-drop', onPsyDrop); + return () => containerEl.removeEventListener('psy-drop', onPsyDrop); + }, [containerEl, setLyricsSources]); + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isPsyDragging || !containerEl) return; + const rows = containerEl.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')} +

+ + {/* Mode switch — standard three-provider pipeline vs. YouLyPlus karaoke. + YouLyPlus misses silently fall back to the standard pipeline. */} +
+
+
+
{t('settings.lyricsModeLyricsplus')}
+
{t('settings.lyricsModeLyricsplusDesc')}
+
+ +
+
+
+
+
+
{t('settings.lyricsModeStandard')}
+
{t('settings.lyricsModeStandardDesc')}
+
+ +
+
+ + {lyricsMode === 'standard' && ( +
+ {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} + +
+ ); + })} +
+ )} + + {/* Static-only toggle — suppresses line/word tracking in both modes. */} +
+
+
+
{t('settings.lyricsStaticOnly')}
+
{t('settings.lyricsStaticOnlyDesc')}
+
+ +
+
+
+ ); +} diff --git a/src/components/settings/QueueToolbarCustomizer.tsx b/src/components/settings/QueueToolbarCustomizer.tsx new file mode 100644 index 00000000..b5f10305 --- /dev/null +++ b/src/components/settings/QueueToolbarCustomizer.tsx @@ -0,0 +1,139 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FolderOpen, GripVertical, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves } from 'lucide-react'; +import { useDragDrop, useDragSource } from '../../contexts/DragDropContext'; +import { useQueueToolbarStore, QueueToolbarButtonId } from '../../store/queueToolbarStore'; + +type QueueToolbarDropTarget = { idx: number; before: boolean } | null; + +const QUEUE_TOOLBAR_BUTTON_ICONS: Record = { + shuffle: Shuffle, + save: Save, + load: FolderOpen, + share: Share2, + clear: Trash2, + separator: null, // No icon for separator + gapless: MoveRight, + crossfade: Waves, + infinite: Infinity, +}; + +const QUEUE_TOOLBAR_LABEL_KEYS: Record = { + shuffle: 'queue.shuffle', + save: 'queue.savePlaylist', + load: 'queue.loadPlaylist', + share: 'queue.shareQueue', + clear: 'queue.clear', + separator: 'settings.queueToolbarSeparator', + gapless: 'queue.gapless', + crossfade: 'queue.crossfade', + infinite: 'queue.infiniteQueue', +}; + +function QueueToolbarGripHandle({ idx, label }: { idx: number; label: string }) { + const { t } = useTranslation(); + const { onMouseDown } = useDragSource(() => ({ + data: JSON.stringify({ type: 'queue_toolbar_reorder', index: idx }), + label, + })); + return ( + + + + ); +} + +export function QueueToolbarCustomizer() { + const { t } = useTranslation(); + const { buttons, setButtons, toggleButton } = useQueueToolbarStore(); + const { isDragging: isPsyDragging } = useDragDrop(); + const containerRef = useRef(null); + const [dropTarget, setDropTarget] = useState(null); + const dropTargetRef = useRef(null); + const buttonsRef = useRef(buttons); + buttonsRef.current = buttons; + + 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 !== 'queue_toolbar_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 = [...buttonsRef.current]; + const [moved] = next.splice(fromIdx, 1); + next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); + setButtons(next); + }; + el.addEventListener('psy-drop', onPsyDrop); + return () => el.removeEventListener('psy-drop', onPsyDrop); + }, [setButtons]); + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isPsyDragging || !containerRef.current) return; + const rows = containerRef.current.querySelectorAll('[data-queue-toolbar-idx]'); + let target: QueueToolbarDropTarget = null; + for (const row of rows) { + const rect = row.getBoundingClientRect(); + const idx = Number(row.dataset.queueToolbarIdx); + if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } + target = { idx, before: false }; + } + dropTargetRef.current = target; + setDropTarget(target); + }; + + return ( +
+ {buttons.map((btn, idx) => { + const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id]; + const label = t(QUEUE_TOOLBAR_LABEL_KEYS[btn.id]); + const isBefore = isPsyDragging && dropTarget?.idx === idx && dropTarget.before; + const isAfter = isPsyDragging && dropTarget?.idx === idx && !dropTarget.before; + return ( +
+ + {Icon ? ( + + ) : ( +
+ )} + {label} + +
+ ); + })} +
+ ); +} diff --git a/src/components/settings/ServerGripHandle.tsx b/src/components/settings/ServerGripHandle.tsx new file mode 100644 index 00000000..bd4af1be --- /dev/null +++ b/src/components/settings/ServerGripHandle.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next'; +import { GripVertical } from 'lucide-react'; +import { useDragSource } from '../../contexts/DragDropContext'; + +export function ServerGripHandle({ idx, label }: { idx: number; label: string }) { + const { t } = useTranslation(); + const { onMouseDown } = useDragSource(() => ({ + data: JSON.stringify({ type: 'server_reorder', index: idx }), + label, + })); + return ( + e.stopPropagation()} + > + + + ); +} diff --git a/src/components/settings/SidebarCustomizer.tsx b/src/components/settings/SidebarCustomizer.tsx new file mode 100644 index 00000000..7c60f5d7 --- /dev/null +++ b/src/components/settings/SidebarCustomizer.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GripVertical } from 'lucide-react'; +import { useDragDrop, useDragSource } from '../../contexts/DragDropContext'; +import { useAuthStore } from '../../store/authStore'; +import { useSidebarStore, SidebarItemConfig } from '../../store/sidebarStore'; +import { useLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable'; +import { ALL_NAV_ITEMS } from '../../config/navItems'; +import { applySidebarDropReorder } from '../../utils/sidebarNavReorder'; + +type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null; + +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 ( + + + + ); +} + +export function SidebarCustomizer() { + const { t } = useTranslation(); + const { items, setItems, toggleItem } = 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 luckyMixBase = useLuckyMixAvailable(); + const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate'; + + 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' || cfg.id === 'luckyMix')) return false; + if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false; + if (cfg.id === 'luckyMix' && !luckyMixAvailable) 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); + + const next = applySidebarDropReorder(itemsRef.current, fromSection, fromIdx, target, randomNavMode); + if (next) setItems(next); + }; + el.addEventListener('psy-drop', onPsyDrop); + return () => el.removeEventListener('psy-drop', onPsyDrop); + }, [libraryItems, systemItems, setItems, randomNavMode]); + + 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.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')} +
+
+
+ + ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ceff53b8..72a461af 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import type { ServerProfile, SeekbarStyle, LyricsSourceId, LyricsSourceConfig, LoggingMode, LoudnessLufsPreset, TrackPreviewLocation } from '../store/authStoreTypes'; +import type { ServerProfile, SeekbarStyle, LoggingMode, LoudnessLufsPreset, TrackPreviewLocation } from '../store/authStoreTypes'; import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB, MIX_MIN_RATING_FILTER_MAX_STARS, TRACK_PREVIEW_LOCATIONS } from '../store/authStoreDefaults'; import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; @@ -7,11 +7,10 @@ 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, Scale, ListMusic, Save, Infinity, Share2, MoveRight + PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Download, Waves, Star, Clock, ZoomIn, Sparkles, AlertTriangle, Maximize2, AudioLines, User, Lock, + Users, UserPlus, Shield, Wand2, Search, Scale, ListMusic } 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'; @@ -26,9 +25,7 @@ import CustomSelect from '../components/CustomSelect'; import SettingsSubSection from '../components/SettingsSubSection'; import LicensesPanel from '../components/LicensesPanel'; 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 } from '../store/authStore'; import { SeekbarPreview } from '../components/WaveformSeek'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '../utils/platform'; @@ -37,16 +34,21 @@ import { useFontStore, FontId } from '../store/fontStore'; import { useKeybindingsStore, KeyAction, formatBinding, buildInAppBinding } from '../store/keybindingsStore'; import { useGlobalShortcutsStore, GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../store/globalShortcutsStore'; import { IN_APP_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_ACTIONS } from '../config/shortcutActions'; -import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../store/sidebarStore'; -import { useQueueToolbarStore, QueueToolbarButtonId, QueueToolbarButtonConfig } from '../store/queueToolbarStore'; +import { useSidebarStore } from '../store/sidebarStore'; +import { useQueueToolbarStore } from '../store/queueToolbarStore'; import { effectiveLoudnessPreAnalysisAttenuationDb, } from '../utils/loudnessPreAnalysisSlider'; -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 { useArtistLayoutStore } from '../store/artistLayoutStore'; +import { useHomeStore } from '../store/homeStore'; +import { useDragDrop } from '../contexts/DragDropContext'; +import { ArtistLayoutCustomizer } from '../components/settings/ArtistLayoutCustomizer'; +import { BackupSection } from '../components/settings/BackupSection'; +import { HomeCustomizer } from '../components/settings/HomeCustomizer'; +import { LyricsSourcesCustomizer } from '../components/settings/LyricsSourcesCustomizer'; +import { QueueToolbarCustomizer } from '../components/settings/QueueToolbarCustomizer'; +import { ServerGripHandle } from '../components/settings/ServerGripHandle'; +import { SidebarCustomizer } from '../components/settings/SidebarCustomizer'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; import { ndLogin, ndListUsers, ndCreateUser, ndUpdateUser, ndDeleteUser, @@ -1647,6 +1649,7 @@ export default function Settings() { const searchResultsListRef = useRef(null); // Server-Liste DnD + type ServerDropTarget = { idx: number; before: boolean } | null; const psyDragState = useDragDrop(); const [serverContainerEl, setServerContainerEl] = useState(null); const [serverDropTarget, setServerDropTarget] = useState(null); @@ -4563,736 +4566,3 @@ const TAB_LABEL_KEY: Record = { users: 'settings.tabUsers', }; -function HomeCustomizer() { - const { t } = useTranslation(); - const { sections, toggleSection } = useHomeStore(); - - const SECTION_LABELS: Record = { - hero: t('home.hero'), - recent: t('home.recent'), - discover: t('home.discover'), - becauseYouLike: t('home.becauseYouLike'), - discoverSongs: t('home.discoverSongs'), - discoverArtists: t('home.discoverArtists'), - recentlyPlayed: t('home.recentlyPlayed'), - starred: t('home.starred'), - mostPlayed: t('home.mostPlayed'), - losslessAlbums: t('home.losslessAlbums'), - }; - - return ( -
- {sections.map(sec => ( -
- {SECTION_LABELS[sec.id]} - -
- ))} -
- ); -} - -// ── Queue Toolbar Customizer ─────────────────────────────────────────────── - -type QueueToolbarDropTarget = { idx: number; before: boolean } | null; - -const QUEUE_TOOLBAR_BUTTON_ICONS: Record = { - shuffle: Shuffle, - save: Save, - load: FolderOpen, - share: Share2, - clear: Trash2, - separator: null, // No icon for separator - gapless: MoveRight, - crossfade: Waves, - infinite: Infinity, -}; - -const QUEUE_TOOLBAR_LABEL_KEYS: Record = { - shuffle: 'queue.shuffle', - save: 'queue.savePlaylist', - load: 'queue.loadPlaylist', - share: 'queue.shareQueue', - clear: 'queue.clear', - separator: 'settings.queueToolbarSeparator', - gapless: 'queue.gapless', - crossfade: 'queue.crossfade', - infinite: 'queue.infiniteQueue', -}; - -function QueueToolbarGripHandle({ idx, label }: { idx: number; label: string }) { - const { t } = useTranslation(); - const { onMouseDown } = useDragSource(() => ({ - data: JSON.stringify({ type: 'queue_toolbar_reorder', index: idx }), - label, - })); - return ( - - - - ); -} - -function QueueToolbarCustomizer() { - const { t } = useTranslation(); - const { buttons, setButtons, toggleButton } = useQueueToolbarStore(); - const { isDragging: isPsyDragging } = useDragDrop(); - const containerRef = useRef(null); - const [dropTarget, setDropTarget] = useState(null); - const dropTargetRef = useRef(null); - const buttonsRef = useRef(buttons); - buttonsRef.current = buttons; - - 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 !== 'queue_toolbar_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 = [...buttonsRef.current]; - const [moved] = next.splice(fromIdx, 1); - next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); - setButtons(next); - }; - el.addEventListener('psy-drop', onPsyDrop); - return () => el.removeEventListener('psy-drop', onPsyDrop); - }, [setButtons]); - - const handleMouseMove = (e: React.MouseEvent) => { - if (!isPsyDragging || !containerRef.current) return; - const rows = containerRef.current.querySelectorAll('[data-queue-toolbar-idx]'); - let target: QueueToolbarDropTarget = null; - for (const row of rows) { - const rect = row.getBoundingClientRect(); - const idx = Number(row.dataset.queueToolbarIdx); - if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } - target = { idx, before: false }; - } - dropTargetRef.current = target; - setDropTarget(target); - }; - - return ( -
- {buttons.map((btn, idx) => { - const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id]; - const label = t(QUEUE_TOOLBAR_LABEL_KEYS[btn.id]); - const isBefore = isPsyDragging && dropTarget?.idx === idx && dropTarget.before; - const isAfter = isPsyDragging && dropTarget?.idx === idx && !dropTarget.before; - return ( -
- - {Icon ? ( - - ) : ( -
- )} - {label} - -
- ); - })} -
- ); -} - -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; - -type ServerDropTarget = { idx: number; before: boolean } | null; - -function ServerGripHandle({ idx, label }: { idx: number; label: string }) { - const { t } = useTranslation(); - const { onMouseDown } = useDragSource(() => ({ - data: JSON.stringify({ type: 'server_reorder', index: idx }), - label, - })); - return ( - e.stopPropagation()} - > - - - ); -} - -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 lyricsMode = useAuthStore(s => s.lyricsMode); - const setLyricsMode = useAuthStore(s => s.setLyricsMode); - const lyricsStaticOnly = useAuthStore(s => s.lyricsStaticOnly); - const setLyricsStaticOnly = useAuthStore(s => s.setLyricsStaticOnly); - const { isDragging: isPsyDragging } = useDragDrop(); - // useState (not useRef) so the listener-effect re-runs when the container - // gets unmounted/remounted by the {lyricsMode === 'standard'} wrapper. - const [containerEl, setContainerEl] = useState(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(() => { - if (!containerEl) 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); - }; - containerEl.addEventListener('psy-drop', onPsyDrop); - return () => containerEl.removeEventListener('psy-drop', onPsyDrop); - }, [containerEl, setLyricsSources]); - - const handleMouseMove = (e: React.MouseEvent) => { - if (!isPsyDragging || !containerEl) return; - const rows = containerEl.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')} -

- - {/* Mode switch — standard three-provider pipeline vs. YouLyPlus karaoke. - YouLyPlus misses silently fall back to the standard pipeline. */} -
-
-
-
{t('settings.lyricsModeLyricsplus')}
-
{t('settings.lyricsModeLyricsplusDesc')}
-
- -
-
-
-
-
-
{t('settings.lyricsModeStandard')}
-
{t('settings.lyricsModeStandardDesc')}
-
- -
-
- - {lyricsMode === 'standard' && ( -
- {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} - -
- ); - })} -
- )} - - {/* Static-only toggle — suppresses line/word tracking in both modes. */} -
-
-
-
{t('settings.lyricsStaticOnly')}
-
{t('settings.lyricsStaticOnlyDesc')}
-
- -
-
-
- ); -} - -// ── Sidebar Customizer ────────────────────────────────────────────────────── - -type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null; - -function SidebarCustomizer() { - const { t } = useTranslation(); - const { items, setItems, toggleItem } = 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 luckyMixBase = useLuckyMixAvailable(); - const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate'; - - 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' || cfg.id === 'luckyMix')) return false; - if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false; - if (cfg.id === 'luckyMix' && !luckyMixAvailable) 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); - - const next = applySidebarDropReorder(itemsRef.current, fromSection, fromIdx, target, randomNavMode); - if (next) setItems(next); - }; - el.addEventListener('psy-drop', onPsyDrop); - return () => el.removeEventListener('psy-drop', onPsyDrop); - }, [libraryItems, systemItems, setItems, randomNavMode]); - - 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.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')} -
-
-
- - ); -} - -// ── Artist Page Sections Customizer ──────────────────────────────────────── - -const ARTIST_SECTION_LABEL_KEYS: Record = { - bio: 'settings.artistLayoutBio', - topTracks: 'settings.artistLayoutTopTracks', - similar: 'settings.artistLayoutSimilar', - albums: 'settings.artistLayoutAlbums', - featured: 'settings.artistLayoutFeatured', -}; - -type ArtistDropTarget = { idx: number; before: boolean } | null; - -function ArtistSectionGripHandle({ idx, label }: { idx: number; label: string }) { - const { t } = useTranslation(); - const { onMouseDown } = useDragSource(() => ({ - data: JSON.stringify({ type: 'artist_section_reorder', index: idx }), - label, - })); - return ( - - - - ); -} - -function ArtistLayoutCustomizer() { - const { t } = useTranslation(); - const sections = useArtistLayoutStore(s => s.sections); - const setSections = useArtistLayoutStore(s => s.setSections); - const toggleSection = useArtistLayoutStore(s => s.toggleSection); - const { isDragging: isPsyDragging } = useDragDrop(); - const [containerEl, setContainerEl] = useState(null); - const [dropTarget, setDropTarget] = useState(null); - const dropTargetRef = useRef(null); - const sectionsRef = useRef(sections); - sectionsRef.current = sections; - - useEffect(() => { - if (!isPsyDragging) { dropTargetRef.current = null; setDropTarget(null); } - }, [isPsyDragging]); - - useEffect(() => { - if (!containerEl) 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 !== 'artist_section_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 = [...sectionsRef.current]; - const [moved] = next.splice(fromIdx, 1); - next.splice(insertBefore > fromIdx ? insertBefore - 1 : insertBefore, 0, moved); - setSections(next); - }; - containerEl.addEventListener('psy-drop', onPsyDrop); - return () => containerEl.removeEventListener('psy-drop', onPsyDrop); - }, [containerEl, setSections]); - - const handleMouseMove = (e: React.MouseEvent) => { - if (!isPsyDragging || !containerEl) return; - const rows = containerEl.querySelectorAll('[data-artist-idx]'); - let target: ArtistDropTarget = null; - for (const row of rows) { - const rect = row.getBoundingClientRect(); - const idx = Number(row.dataset.artistIdx); - if (e.clientY < rect.top + rect.height / 2) { target = { idx, before: true }; break; } - target = { idx, before: false }; - } - dropTargetRef.current = target; - setDropTarget(target); - }; - - return ( - <> -

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

-
- {sections.map((section: ArtistSectionConfig, i) => { - const label = t(ARTIST_SECTION_LABEL_KEYS[section.id]); - const isBefore = isPsyDragging && dropTarget?.idx === i && dropTarget.before; - const isAfter = isPsyDragging && dropTarget?.idx === i && !dropTarget.before; - return ( -
- - {label} - -
- ); - })} -
- - ); -} - -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')}
-
- -
-
-
- ); -} -