diff --git a/CHANGELOG.md b/CHANGELOG.md index b4891f8f..94114b55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * AutoDJ is now its own mode rather than a sub-option of Crossfade — its own button in the queue toolbar and its own entry in the audio settings. Crossfade, AutoDJ and Gapless are mutually exclusive (only one active at a time) under a single Off / Gapless / Crossfade / AutoDJ picker, the playback settings are regrouped into clearer Normalization / Track transitions / Queue behaviour panels, and the queue toolbar's separate Save and Load playlist buttons are combined into one Playlist menu (existing toolbar layouts are preserved). Off by default; classic Crossfade is unchanged. +## Changed + +### Settings — consistent grouped layout + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1126](https://github.com/Psychotoxical/psysonic/pull/1126)** + +* The settings tabs now group related controls into clearly bordered, labelled panels for a more consistent, easier-to-scan layout — across Appearance, System, Audio, Storage, Library, Integrations, Music Network, Lyrics, Personalisation, Input and Themes. Standalone toggles are left as they were, and a few duplicated section titles are gone. +* The **Lucky Mix menu** toggle moved from the Library tab to the sidebar customizer, alongside the other navigation toggles. +* The **Native Hi-Res Playback** description now explains what turning it on actually does — play each track at its original sample rate, matching the audio device to the file, instead of resampling everything to 44.1 kHz. The old wording described the off state and read as if the option forced 44.1 kHz. + + ## Fixed ### Seeking in streamed Opus/Ogg tracks diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 36aa2d2c..6778a9b9 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -25,6 +25,10 @@ Within each section, order by **user impact** (most noticeable first) — not PR - Version numbers on store themes and ones you have installed make it obvious when an update is ready. - Filter for **animated** or **static** themes only — less scrolling when you already know the look you want. +### Settings — tidier and easier to scan + +- Settings are grouped into clear, labelled panels so related options sit together — less hunting around. The **Native Hi-Res Playback** option now explains in plain language what it actually does. + ## Fixed ### Playback and audio diff --git a/src/components/settings/AnalyticsStrategySection.tsx b/src/components/settings/AnalyticsStrategySection.tsx index ff5de3f7..c621ba9d 100644 --- a/src/components/settings/AnalyticsStrategySection.tsx +++ b/src/components/settings/AnalyticsStrategySection.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { save as saveDialog } from '@tauri-apps/plugin-dialog'; import { writeFile } from '@tauri-apps/plugin-fs'; import SettingsSubSection from '../SettingsSubSection'; +import { SettingsGroup } from './SettingsGroup'; import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore'; import { useAuthStore } from '../../store/authStore'; import { @@ -253,11 +254,12 @@ export default function AnalyticsStrategySection() { icon={} >
+
- -
+ {t('settings.analyticsStrategyServerLabel')} @@ -284,7 +286,7 @@ export default function AnalyticsStrategySection() { const label = serverListDisplayLabel(server, servers); return (
+ {label} @@ -365,7 +367,7 @@ export default function AnalyticsStrategySection() { key={serverId} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }} > - +
{serverId}
{t('settings.analyticsStrategyServerRemoved')} @@ -432,6 +434,7 @@ export default function AnalyticsStrategySection() { {t('settings.analyticsStrategyAdvancedWarning')}
+ {clearTarget && diff --git a/src/components/settings/AppearanceTab.tsx b/src/components/settings/AppearanceTab.tsx index 9673cfb0..f1f44adc 100644 --- a/src/components/settings/AppearanceTab.tsx +++ b/src/components/settings/AppearanceTab.tsx @@ -12,6 +12,8 @@ import { useFontStore, FontId } from '../../store/fontStore'; import { useThemeStore } from '../../store/themeStore'; import { IS_LINUX, IS_WINDOWS } from '../../utils/platform'; import SettingsSubSection from '../SettingsSubSection'; +import { SettingsGroup } from './SettingsGroup'; +import { SettingsToggle } from './SettingsToggle'; import { SeekbarPreview } from '../WaveformSeekPreview'; import WindowButtonPreview from '../WindowButtonPreview'; @@ -34,38 +36,40 @@ export function AppearanceTab() { icon={} >
-
- {t('settings.libraryGridMaxColumnsPerfHint')} -
-
- -
- auth.setLibraryGridMaxColumns(Number(e.target.value))} - style={{ flex: 1, maxWidth: 360 }} - aria-valuemin={LIBRARY_GRID_MAX_COLUMNS_MIN} - aria-valuemax={LIBRARY_GRID_MAX_COLUMNS_MAX} - aria-valuenow={auth.libraryGridMaxColumns} - /> - - {auth.libraryGridMaxColumns} - + +
+ {t('settings.libraryGridMaxColumnsPerfHint')}
-
-

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

+
+ +
+ auth.setLibraryGridMaxColumns(Number(e.target.value))} + style={{ flex: 1, maxWidth: 360 }} + aria-valuemin={LIBRARY_GRID_MAX_COLUMNS_MIN} + aria-valuemax={LIBRARY_GRID_MAX_COLUMNS_MAX} + aria-valuenow={auth.libraryGridMaxColumns} + /> + + {auth.libraryGridMaxColumns} + +
+
+

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

+
@@ -74,103 +78,69 @@ export function AppearanceTab() { icon={} >
-
-
-
{t('settings.coverArtBackground')}
-
{t('settings.coverArtBackgroundSub')}
-
- -
-
-
-
-
{t('settings.playlistCoverPhoto')}
-
{t('settings.playlistCoverPhotoSub')}
-
- -
-
-
-
-
{t('settings.showBitrate')}
-
{t('settings.showBitrateSub')}
-
- -
-
-
-
-
{t('settings.floatingPlayerBar')}
-
{t('settings.floatingPlayerBarSub')}
-
- -
-
-
-
-
{t('settings.showArtistImages')}
-
{t('settings.showArtistImagesDesc')}
-
- -
-
-
-
-
{t('settings.showOrbitTrigger')}
-
{t('settings.showOrbitTriggerDesc')}
-
- -
- {!IS_WINDOWS && ( - <> -
-
-
-
{t('settings.preloadMiniPlayer')}
-
{t('settings.preloadMiniPlayerDesc')}
-
- -
- - )} + + +
+ +
+ +
+ +
+ +
+ + {!IS_WINDOWS && ( + <> +
+ + + )} + + {IS_LINUX && !isTilingWm && ( - <> -
-
-
-
{t('settings.useCustomTitlebar')}
-
{t('settings.useCustomTitlebarDesc')}
-
- -
+ + {auth.useCustomTitlebar && ( <>
@@ -192,19 +162,15 @@ export function AppearanceTab() {
-
-
-
{t('settings.showMinimizeButton')}
-
{t('settings.showMinimizeButtonDesc')}
-
- -
+ )} - + )}
@@ -214,57 +180,59 @@ export function AppearanceTab() { icon={} >
-
-
- {t('settings.uiScaleLabel')} - - {Math.round(fontStore.uiScale * 100)}% - + +
+
+ {t('settings.uiScaleLabel')} + + {Math.round(fontStore.uiScale * 100)}% + +
+ {(() => { + const presets = [80, 90, 100, 110, 125, 150]; + const currentPct = Math.round(fontStore.uiScale * 100); + let idx = presets.indexOf(currentPct); + if (idx < 0) { + // Snap legacy off-preset values to the closest preset. + idx = presets.reduce((best, p, i) => + Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0); + } + return ( + <> + fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)} + className="ui-scale-slider" + /> +
+ {presets.map(p => { + const active = currentPct === p; + return ( + + ); + })} +
+ + ); + })()}
- {(() => { - const presets = [80, 90, 100, 110, 125, 150]; - const currentPct = Math.round(fontStore.uiScale * 100); - let idx = presets.indexOf(currentPct); - if (idx < 0) { - // Snap legacy off-preset values to the closest preset. - idx = presets.reduce((best, p, i) => - Math.abs(p - currentPct) < Math.abs(presets[best] - currentPct) ? i : best, 0); - } - return ( - <> - fontStore.setUiScale(presets[parseInt(e.target.value, 10)] / 100)} - className="ui-scale-slider" - /> -
- {presets.map(p => { - const active = currentPct === p; - return ( - - ); - })} -
- - ); - })()} -
+
@@ -273,47 +241,49 @@ export function AppearanceTab() { icon={} >
-
- {( - [ - // Accessibility-first: OpenDyslexic at the top so dyslexic - // readers don't have to scroll past 14 sans-serifs to find it. - { id: 'opendyslexic', label: 'OpenDyslexic', stack: "'OpenDyslexic', sans-serif", hint: t('settings.fontHintOpenDyslexic') }, - { 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" }, - { id: 'golos-text', label: 'Golos Text', stack: "'Golos Text Variable', sans-serif" }, - { id: 'unbounded', label: 'Unbounded', stack: "'Unbounded Variable', sans-serif" }, - ] as { id: FontId; label: string; stack: string; hint?: string }[] - ).map(f => ( - - ))} -
+ +
+ {( + [ + // Accessibility-first: OpenDyslexic at the top so dyslexic + // readers don't have to scroll past 14 sans-serifs to find it. + { id: 'opendyslexic', label: 'OpenDyslexic', stack: "'OpenDyslexic', sans-serif", hint: t('settings.fontHintOpenDyslexic') }, + { 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" }, + { id: 'golos-text', label: 'Golos Text', stack: "'Golos Text Variable', sans-serif" }, + { id: 'unbounded', label: 'Unbounded', stack: "'Unbounded Variable', sans-serif" }, + ] as { id: FontId; label: string; stack: string; hint?: string }[] + ).map(f => ( + + ))} +
+
@@ -322,20 +292,22 @@ export function AppearanceTab() { icon={} >
-
- {t('settings.seekbarStyleDesc')} -
-
- {(['truewave', 'pseudowave', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => ( - auth.setSeekbarStyle(style)} - /> - ))} -
+ +
+ {t('settings.seekbarStyleDesc')} +
+
+ {(['truewave', 'pseudowave', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => ( + auth.setSeekbarStyle(style)} + /> + ))} +
+
diff --git a/src/components/settings/ArtistLayoutCustomizer.tsx b/src/components/settings/ArtistLayoutCustomizer.tsx index b21f992c..a5da1f4f 100644 --- a/src/components/settings/ArtistLayoutCustomizer.tsx +++ b/src/components/settings/ArtistLayoutCustomizer.tsx @@ -94,7 +94,6 @@ export function ArtistLayoutCustomizer() { {t('settings.artistLayoutDesc')}

} >
-
-
-
{t('settings.hiResEnabled')}
-
{t('settings.hiResDesc')}
-
- -
+ + +
@@ -75,7 +71,9 @@ export function AudioTab() { icon={} >
- + + +
@@ -85,7 +83,9 @@ export function AudioTab() { icon={} >
- + + +
diff --git a/src/components/settings/BackupSection.tsx b/src/components/settings/BackupSection.tsx index e7708878..b91b5ac0 100644 --- a/src/components/settings/BackupSection.tsx +++ b/src/components/settings/BackupSection.tsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Clock3, Download, HardDrive, Upload } from 'lucide-react'; +import { Clock3, Download, Upload } from 'lucide-react'; import { createPortal } from 'react-dom'; +import { SettingsGroup } from './SettingsGroup'; import { exportBackupToPath, importAnyBackupFromPath, @@ -148,13 +149,8 @@ export function BackupSection() { : null; return ( -
-
- -

{t('settings.backupTitle')}

-
- -
+
+
{(['full', 'library', 'config'] as BackupMode[]).map(candidate => (
-
+ {busyOverlay} -
+
); } diff --git a/src/components/settings/CoverCacheStrategySection.tsx b/src/components/settings/CoverCacheStrategySection.tsx index 8ef00a5a..74cca612 100644 --- a/src/components/settings/CoverCacheStrategySection.tsx +++ b/src/components/settings/CoverCacheStrategySection.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'r import { useTranslation } from 'react-i18next'; import { listen } from '@tauri-apps/api/event'; import SettingsSubSection from '../SettingsSubSection'; +import { SettingsGroup } from './SettingsGroup'; import { useCoverStrategyStore } from '../../store/coverStrategyStore'; import { useAuthStore } from '../../store/authStore'; import { @@ -232,12 +233,13 @@ export default function CoverCacheStrategySection() { return ( }>
+
- + @@ -245,7 +247,7 @@ export default function CoverCacheStrategySection() { - - + @@ -299,7 +301,7 @@ export default function CoverCacheStrategySection() { const label = serverListDisplayLabel(server, servers); return ( - + - @@ -414,6 +416,7 @@ export default function CoverCacheStrategySection() { )} + ); diff --git a/src/components/settings/HomeCustomizer.tsx b/src/components/settings/HomeCustomizer.tsx index ffc1c3ec..780560de 100644 --- a/src/components/settings/HomeCustomizer.tsx +++ b/src/components/settings/HomeCustomizer.tsx @@ -19,7 +19,7 @@ export function HomeCustomizer() { }; return ( -
+
{sections.map(sec => (
{SECTION_LABELS[sec.id]} diff --git a/src/components/settings/InputTab.tsx b/src/components/settings/InputTab.tsx index 1fb6a43a..c15df108 100644 --- a/src/components/settings/InputTab.tsx +++ b/src/components/settings/InputTab.tsx @@ -5,6 +5,7 @@ import { IN_APP_SHORTCUT_ACTIONS, GLOBAL_SHORTCUT_ACTIONS } from '../../config/s import { useGlobalShortcutsStore, type GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../../store/globalShortcutsStore'; import { useKeybindingsStore, type KeyAction, buildInAppBinding, formatBinding } from '../../store/keybindingsStore'; import SettingsSubSection from '../SettingsSubSection'; +import { SettingsGroup } from './SettingsGroup'; export function InputTab() { const { t } = useTranslation(); @@ -32,69 +33,71 @@ export function InputTab() { } >
-
- {IN_APP_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => { - const label = getLabel(t); - const bound = kb.bindings[action]; - const isListening = listeningFor === action; - return ( -
- {label} -
- - {bound && !isListening && ( - - )} + {bound && !isListening && ( + + )} +
-
- ); - })} -
+ ); + })} +
+
@@ -116,67 +119,69 @@ export function InputTab() { } >
-
- {GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => { - const label = getLabel(t); - const bound = gs.shortcuts[action] ?? null; - const isListening = listeningForGlobal === action; - return ( -
- {label} -
- - {bound && !isListening && ( + +
+ {GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => { + const label = getLabel(t); + const bound = gs.shortcuts[action] ?? null; + const isListening = listeningForGlobal === action; + return ( +
+ {label} +
- )} + {bound && !isListening && ( + + )} +
-
- ); - })} -
+ ); + })} +
+
diff --git a/src/components/settings/InstalledThemes.tsx b/src/components/settings/InstalledThemes.tsx index 8b06bd6a..dd56574e 100644 --- a/src/components/settings/InstalledThemes.tsx +++ b/src/components/settings/InstalledThemes.tsx @@ -71,7 +71,7 @@ export function InstalledThemes() { ]; return ( -
+
{cards.map(c => { const isActive = active === c.id; diff --git a/src/components/settings/IntegrationsTab.tsx b/src/components/settings/IntegrationsTab.tsx index 947aa1df..a54f7759 100644 --- a/src/components/settings/IntegrationsTab.tsx +++ b/src/components/settings/IntegrationsTab.tsx @@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next'; import { AlertTriangle, Info, Sparkles, Wifi } from 'lucide-react'; import { useAuthStore } from '../../store/authStore'; import SettingsSubSection from '../SettingsSubSection'; +import { SettingsGroup } from './SettingsGroup'; +import { SettingsToggle } from './SettingsToggle'; import { MusicNetworkSection } from './musicNetwork/MusicNetworkSection'; export function IntegrationsTab() { @@ -41,55 +43,37 @@ export function IntegrationsTab() { > {t('settings.discordRichPresenceNotice')}
-
-
-
{t('settings.discordRichPresence')}
-
{t('settings.discordRichPresenceDesc')}
-
- -
+ + + {auth.discordRichPresence && ( <> -
-
{t('settings.discordCoverNone')}
- -
-
-
{t('settings.discordCoverServer')}
- -
-
-
{t('settings.discordCoverApple')}
- -
-
-
-
{t('settings.discordTemplates')}
-
{t('settings.discordTemplatesDesc')}
+ + auth.setDiscordCoverSource(c ? 'none' : 'server')} + /> +
+ auth.setDiscordCoverSource(c ? 'server' : 'none')} + /> +
+ auth.setDiscordCoverSource(c ? 'apple' : 'none')} + /> + + +
-
+ )}
@@ -142,16 +126,14 @@ export function IntegrationsTab() { icon={} >
-
-
-
{t('settings.enableBandsintown')}
-
{t('settings.enableBandsintownDesc')}
-
- -
+ + +
@@ -161,17 +143,15 @@ export function IntegrationsTab() { icon={} >
-
-
-
{t('settings.nowPlayingEnabled')}
-
{t('settings.nowPlayingEnabledDesc')}
-
{t('settings.nowPlayingPluginNote')}
-
- -
+ + +
diff --git a/src/components/settings/LibraryTab.tsx b/src/components/settings/LibraryTab.tsx index 86b5b424..4535f6f5 100644 --- a/src/components/settings/LibraryTab.tsx +++ b/src/components/settings/LibraryTab.tsx @@ -4,6 +4,8 @@ import { Shuffle, Star } from 'lucide-react'; import { useAuthStore } from '../../store/authStore'; import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults'; import SettingsSubSection from '../SettingsSubSection'; +import { SettingsGroup } from './SettingsGroup'; +import { SettingsToggle } from './SettingsToggle'; import StarRating from '../StarRating'; import AnalyticsStrategySection from './AnalyticsStrategySection'; const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature']; @@ -23,99 +25,80 @@ export function LibraryTab() { icon={} >
-

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

+ +

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

-
{t('settings.randomMixBlacklistTitle')}
-
- {auth.customGenreBlacklist.length === 0 ? ( - {t('settings.randomMixBlacklistEmpty')} - ) : ( - auth.customGenreBlacklist.map(genre => ( - - {genre} - - - )) - )} -
+
{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()) { +
+ 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.luckyMixMenuTitle')}
-
- {t('settings.luckyMixMenuDesc')} -
+ }} + disabled={!newGenre.trim()} + > + {t('settings.randomMixBlacklistAdd')} +
- -
-
- -
{t('settings.randomMixHardcodedTitle')}
-
- {AUDIOBOOK_GENRES_DISPLAY.map(genre => ( - - {genre} - - ))} -
+
{t('settings.randomMixHardcodedTitle')}
+
+ {AUDIOBOOK_GENRES_DISPLAY.map(genre => ( + + {genre} + + ))} +
+
@@ -125,101 +108,93 @@ export function LibraryTab() { icon={} >
-
-
-
{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'), - })} + +
+
+
{t('settings.ratingsSkipStarTitle')}
+
{t('settings.ratingsSkipStarDesc')}
-
- -
- {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} - + {auth.skipStarOnManualSkipsEnabled && ( + <> + + auth.setSkipStarManualSkipThreshold(Number(e.target.value))} + style={{ width: 72, padding: '6px 10px', fontSize: 13 }} + aria-label={t('settings.ratingsSkipStarThresholdLabel')} /> -
- ))} + + )} +
- - )} +
+ +
+ + + {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} + +
+ ))} +
+ + )} +
diff --git a/src/components/settings/LyricsSourcesCustomizer.tsx b/src/components/settings/LyricsSourcesCustomizer.tsx index a4dc21a5..d7d4f78b 100644 --- a/src/components/settings/LyricsSourcesCustomizer.tsx +++ b/src/components/settings/LyricsSourcesCustomizer.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { GripVertical, Music2 } from 'lucide-react'; +import { GripVertical } 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'; +import { SettingsToggle } from './SettingsToggle'; const LYRICS_SOURCE_LABEL_KEYS: Record = { server: 'settings.lyricsSourceServer', @@ -98,11 +99,7 @@ export function LyricsSourcesCustomizer() { }; return ( -
-
- -

{t('settings.lyricsSourcesTitle')}

-
+ <>

{t('settings.lyricsSourcesDesc')}

@@ -110,28 +107,19 @@ export function LyricsSourcesCustomizer() { {/* YouLyPlus (karaoke) — independent toggle. When on it is tried first and the enabled sources below act as fallback; when off only those sources are used. YouLyPlus off + every source off = lyrics fully disabled. */} -
-
-
-
{t('settings.lyricsYouLyPlus')}
-
{t('settings.lyricsYouLyPlusDesc')}
-
- -
+
+
{youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')}
{/* Static-only toggle — suppresses line/word tracking in both modes. */} -
-
-
-
{t('settings.lyricsStaticOnly')}
-
{t('settings.lyricsStaticOnlyDesc')}
-
- -
+
+
-
+ ); } diff --git a/src/components/settings/LyricsTab.tsx b/src/components/settings/LyricsTab.tsx index 9fd8ce73..0365e997 100644 --- a/src/components/settings/LyricsTab.tsx +++ b/src/components/settings/LyricsTab.tsx @@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next'; import { AudioLines, Music2 } from 'lucide-react'; import { useAuthStore } from '../../store/authStore'; import SettingsSubSection from '../SettingsSubSection'; +import { SettingsGroup } from './SettingsGroup'; +import { SettingsToggle } from './SettingsToggle'; import { LyricsSourcesCustomizer } from './LyricsSourcesCustomizer'; export function LyricsTab() { @@ -15,37 +17,32 @@ export function LyricsTab() { title={t('settings.lyricsSourcesTitle')} icon={} > - + + + } > -
- {(['classic', 'apple'] as const).map(style => { + + {(['classic', 'apple'] as const).map((style, i) => { const key = style === 'classic' ? 'Classic' : 'Apple'; const other = style === 'classic' ? 'apple' : 'classic'; return ( -
-
-
-
{t(`settings.sidebarLyricsStyle${key}` as any)}
-
{t(`settings.sidebarLyricsStyle${key}Desc` as any)}
-
- -
+
+ {i > 0 &&
} + setSidebarLyricsStyle(c ? style : other)} + />
); })} -
+ ); diff --git a/src/components/settings/PersonalisationTab.tsx b/src/components/settings/PersonalisationTab.tsx index 58c87926..e06658ec 100644 --- a/src/components/settings/PersonalisationTab.tsx +++ b/src/components/settings/PersonalisationTab.tsx @@ -8,6 +8,8 @@ import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore'; import { useQueueToolbarStore } from '../../store/queueToolbarStore'; import { useSidebarStore } from '../../store/sidebarStore'; import SettingsSubSection from '../SettingsSubSection'; +import { SettingsGroup } from './SettingsGroup'; +import { SettingsToggle } from './SettingsToggle'; import { ArtistLayoutCustomizer } from './ArtistLayoutCustomizer'; import { HomeCustomizer } from './HomeCustomizer'; import { PlayerBarLayoutCustomizer } from './PlayerBarLayoutCustomizer'; @@ -37,7 +39,9 @@ export function PersonalisationTab() { } > - + + + } > - + + + } > - + + + } >
- {/* Three mutually exclusive modes — exactly one is always active, so - turning one on turns the others off; the active one cannot be - switched off directly (ignore the uncheck). */} -
-
-
{t('queue.title')}
-
{t('settings.queueModeQueueSub')}
-
- -
-
-
-
-
{t('queue.modePlaylist')}
-
{t('settings.queueModePlaylistSub')}
-
- -
-
-
-
-
{t('queue.modeTimeline')}
-
{t('settings.queueModeTimelineSub')}
-
- -
+ + {/* Three mutually exclusive modes — exactly one is always active, so + turning one on turns the others off; the active one cannot be + switched off directly (ignore the uncheck). */} + { if (c) setQueueDisplayMode('queue'); }} + /> +
+ { if (c) setQueueDisplayMode('playlist'); }} + /> +
+ { if (c) setQueueDisplayMode('timeline'); }} + /> +
@@ -151,7 +137,9 @@ export function PersonalisationTab() { } > - + + + } > - + + + } > - + + + ); diff --git a/src/components/settings/PlayerBarLayoutCustomizer.tsx b/src/components/settings/PlayerBarLayoutCustomizer.tsx index af3e5817..b3442304 100644 --- a/src/components/settings/PlayerBarLayoutCustomizer.tsx +++ b/src/components/settings/PlayerBarLayoutCustomizer.tsx @@ -35,7 +35,7 @@ export function PlayerBarLayoutCustomizer() { const toggleItem = usePlayerBarLayoutStore(s => s.toggleItem); return ( -
+
{items.map((it) => { const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]); return ( diff --git a/src/components/settings/PlaylistLayoutCustomizer.tsx b/src/components/settings/PlaylistLayoutCustomizer.tsx index 0ed1606d..553e2942 100644 --- a/src/components/settings/PlaylistLayoutCustomizer.tsx +++ b/src/components/settings/PlaylistLayoutCustomizer.tsx @@ -24,7 +24,7 @@ export function PlaylistLayoutCustomizer() { const toggleItem = usePlaylistLayoutStore(s => s.toggleItem); return ( -
+
{items.map((it) => { const Icon = PLAYLIST_LAYOUT_ICONS[it.id]; const label = t(PLAYLIST_LAYOUT_LABEL_KEYS[it.id]); diff --git a/src/components/settings/QueueToolbarCustomizer.tsx b/src/components/settings/QueueToolbarCustomizer.tsx index 548965c5..9561b1ba 100644 --- a/src/components/settings/QueueToolbarCustomizer.tsx +++ b/src/components/settings/QueueToolbarCustomizer.tsx @@ -104,7 +104,7 @@ export function QueueToolbarCustomizer() { }; return ( -
+
{buttons.map((btn, idx) => { const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id]; const label = t(QUEUE_TOOLBAR_LABEL_KEYS[btn.id]); diff --git a/src/components/settings/ServerLibraryIndexControls.tsx b/src/components/settings/ServerLibraryIndexControls.tsx index fd3db21f..7f3d711f 100644 --- a/src/components/settings/ServerLibraryIndexControls.tsx +++ b/src/components/settings/ServerLibraryIndexControls.tsx @@ -63,19 +63,19 @@ export default function ServerLibraryIndexControls({ borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)', }} > -
- {connection === 'offline' && ( - - - {t('settings.libraryIndexServerDeferred')} +
+ {connection === 'offline' ? ( + + + {phaseLabel} - )} - {busy && ( - - {t('settings.libraryIndexServerSyncing')} + ) : busy ? ( + + {t('settings.libraryIndexServerSyncing')} {phaseLabel} + ) : ( + {phaseLabel} )} - {phaseLabel}
+ ); +} diff --git a/src/components/settings/SettingsToggle.tsx b/src/components/settings/SettingsToggle.tsx new file mode 100644 index 00000000..9d2f4328 --- /dev/null +++ b/src/components/settings/SettingsToggle.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react'; + +interface Props { + /** Bold label. Omit for a desc-only row whose title is the enclosing group + * header — pass `ariaLabel` then so the switch keeps an accessible name. */ + label?: string; + /** Muted description under the label (string, or JSX for inline links). */ + desc?: ReactNode; + /** Bold secondary note under the description (e.g. a requirement hint). */ + note?: ReactNode; + checked: boolean; + onChange: (checked: boolean) => void; + /** Dims the row and blocks interaction (e.g. mutually-exclusive options). */ + disabled?: boolean; + /** Overrides the toggle's accessible name when it should differ from the label. */ + ariaLabel?: string; + /** Indexes the row for the settings search (data-settings-search). */ + searchText?: string; + /** Forwarded to the checkbox input (e.g. a tour/onboarding anchor). */ + id?: string; +} + +/** + * Standard settings toggle row — a label/description on the left and a switch + * on the right. Centralises the markup repeated across every settings tab so + * sections only describe what they toggle, not how a toggle row looks. + */ +export function SettingsToggle({ label, desc, note, checked, onChange, disabled, ariaLabel, searchText, id }: Props) { + return ( +
+
+ {label &&
{label}
} + {desc &&
{desc}
} + {note &&
{note}
} +
+ +
+ ); +} diff --git a/src/components/settings/SidebarCustomizer.tsx b/src/components/settings/SidebarCustomizer.tsx index 17da0292..9a350d4f 100644 --- a/src/components/settings/SidebarCustomizer.tsx +++ b/src/components/settings/SidebarCustomizer.tsx @@ -7,6 +7,7 @@ import { useSidebarStore, SidebarItemConfig, CONSERVED_SIDEBAR_NAV_IDS } from '. import { useLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable'; import { ALL_NAV_ITEMS } from '../../config/navItems'; import { applySidebarDropReorder } from '../../utils/componentHelpers/sidebarNavReorder'; +import { SettingsToggle } from './SettingsToggle'; type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null; @@ -41,6 +42,8 @@ export function SidebarCustomizer() { const setRandomNavMode = useAuthStore(s => s.setRandomNavMode); const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop); const setNowPlayingAtTop = useAuthStore(s => s.setNowPlayingAtTop); + const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu); + const setShowLuckyMixMenu = useAuthStore(s => s.setShowLuckyMixMenu); const luckyMixBase = useLuckyMixAvailable(); const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate'; @@ -125,44 +128,35 @@ export function SidebarCustomizer() { return ( <> -
-
-
-
{t('settings.randomNavSplitTitle')}
-
{t('settings.randomNavSplitDesc')}
-
- -
-
-
-
{t('settings.nowPlayingTopTitle')}
-
{t('settings.nowPlayingTopDesc')}
-
- -
+
+ setRandomNavMode(c ? 'separate' : 'hub')} + /> + +
{/* 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'))}
diff --git a/src/components/settings/StorageTab.tsx b/src/components/settings/StorageTab.tsx index f33c8a47..86a0da1d 100644 --- a/src/components/settings/StorageTab.tsx +++ b/src/components/settings/StorageTab.tsx @@ -8,6 +8,8 @@ import { countHotCacheTracks } from '../../store/hotCacheStore'; import { useLocalPlaybackStore } from '../../store/localPlaybackStore'; import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes'; import SettingsSubSection from '../SettingsSubSection'; +import { SettingsGroup } from './SettingsGroup'; +import { SettingsToggle } from './SettingsToggle'; import CoverCacheStrategySection from './CoverCacheStrategySection'; export function StorageTab() { @@ -74,36 +76,35 @@ export function StorageTab() { icon={} >
-
- {t('settings.mediaDirDesc')} -
-
- - {auth.mediaDir && ( - + )} + - )} - -
- {auth.mediaDir && ( -
- {t('settings.mediaDirHint')}
- )} + {auth.mediaDir && ( +
+ {t('settings.mediaDirHint')} +
+ )} +
@@ -114,78 +115,72 @@ export function StorageTab() { icon={} >
-
-
-
{t('settings.hotCacheTitle')}
-
{t('settings.hotCacheDisclaimer')}
-
- -
- - {auth.hotCacheEnabled && ( -
-
-
- {t('settings.cacheUsedHot')} - {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'} -
-
- {t('settings.hotCacheTrackCount')} - {hotCacheTrackCount} -
-
- -
-
{t('settings.hotCacheMaxMb')}
-
- auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" /> - {snapHotCacheMb(auth.hotCacheMaxMb)} MB -
-
-
-
{t('settings.hotCacheDebounce')}
-
- auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} 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)) })} - -
-
- -
- -
- )} + } + }} + /> + + {auth.hotCacheEnabled && ( +
+
+
+ {t('settings.cacheUsedHot')} + {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'} +
+
+ {t('settings.hotCacheTrackCount')} + {hotCacheTrackCount} +
+
+ +
+
{t('settings.hotCacheMaxMb')}
+
+ auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" /> + {snapHotCacheMb(auth.hotCacheMaxMb)} MB +
+
+
+
{t('settings.hotCacheDebounce')}
+
+ auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} 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)) })} + +
+
+ +
+ +
+ )} +
@@ -194,32 +189,31 @@ export function StorageTab() { icon={} >
-
- {t('settings.downloadsFolderDesc')} -
-
- - {auth.downloadFolder && ( - + )} + - )} - -
+
+
diff --git a/src/components/settings/SystemTab.tsx b/src/components/settings/SystemTab.tsx index 6c622bdb..fa5afc22 100644 --- a/src/components/settings/SystemTab.tsx +++ b/src/components/settings/SystemTab.tsx @@ -15,6 +15,8 @@ import { AboutPsysonicBrandHeader } from '../AboutPsysonicLol'; import CustomSelect from '../CustomSelect'; import LicensesPanel from '../LicensesPanel'; import SettingsSubSection from '../SettingsSubSection'; +import { SettingsGroup } from './SettingsGroup'; +import { SettingsToggle } from './SettingsToggle'; import { BackupSection } from './BackupSection'; import { CONTRIBUTORS, MAINTAINERS } from '../../config/settingsCredits'; @@ -55,23 +57,25 @@ export function SystemTab() { icon={} >
-
- 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') }, - { value: 'ro', label: t('settings.languageRo') }, - ]} - /> -
+ +
+ 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') }, + { value: 'ro', label: t('settings.languageRo') }, + ]} + /> +
+
@@ -81,59 +85,37 @@ export function SystemTab() { icon={} >
-
-
-
{t('settings.showTrayIcon')}
-
{t('settings.showTrayIconDesc')}
-
- -
-
-
-
-
{t('settings.minimizeToTray')}
-
{t('settings.minimizeToTrayDesc')}
-
- -
+ + +
+ + + {IS_LINUX && ( - <> + +
-
-
-
{t('settings.linuxWebkitSmoothScroll')}
-
{t('settings.linuxWebkitSmoothScrollDesc')}
-
- -
-
-
-
-
{t('settings.linuxWebkitInputForceRepaint')}
-
{t('settings.linuxWebkitInputForceRepaintDesc')}
-
- -
+ {waylandTextRenderAvailable && ( <>
@@ -155,26 +137,28 @@ export function SystemTab() {
)} - + )} -
-
-
-
{t('settings.clockFormat')}
-
{t('settings.clockFormatDesc')}
+ + +
+
+
{t('settings.clockFormat')}
+
{t('settings.clockFormatDesc')}
+
+
+ auth.setClockFormat(v as ClockFormat)} + options={[ + { value: 'auto', label: t('settings.clockFormatAuto') }, + { value: '24h', label: t('settings.clockFormatTwentyFour') }, + { value: '12h', label: t('settings.clockFormatTwelve') }, + ]} + /> +
-
- auth.setClockFormat(v as ClockFormat)} - options={[ - { value: 'auto', label: t('settings.clockFormatAuto') }, - { value: '24h', label: t('settings.clockFormatTwentyFour') }, - { value: '12h', label: t('settings.clockFormatTwelve') }, - ]} - /> -
-
+
@@ -190,26 +174,28 @@ export function SystemTab() { icon={} >
-
- {t('settings.loggingModeDesc')} -
- auth.setLoggingMode(v as LoggingMode)} - options={[ - { value: 'off', label: t('settings.loggingModeOff') }, - { value: 'normal', label: t('settings.loggingModeNormal') }, - { value: 'debug', label: t('settings.loggingModeDebug') }, - ]} - /> - {auth.loggingMode === 'debug' && ( -
- + +
+ {t('settings.loggingModeDesc')}
- )} + auth.setLoggingMode(v as LoggingMode)} + options={[ + { value: 'off', label: t('settings.loggingModeOff') }, + { value: 'normal', label: t('settings.loggingModeNormal') }, + { value: 'debug', label: t('settings.loggingModeDebug') }, + ]} + /> + {auth.loggingMode === 'debug' && ( +
+ +
+ )} +
@@ -271,20 +257,12 @@ export function SystemTab() {
-
-
-
{t('settings.showChangelogOnUpdate')}
-
{t('settings.showChangelogOnUpdateDesc')}
-
- -
+
-
- - )} + ) : ( + <> +
+ {t('settings.audioOutputDeviceDesc')} +
+
+ { + const device = val || null; + setDeviceSwitching(true); + try { + await invoke('audio_set_device', { deviceName: device }); + setAudioOutputDevice(device); + } catch { /* device open failed — don't persist */ } + setDeviceSwitching(false); + }} + options={buildAudioDeviceSelectOptions( + audioDevices, + t('settings.audioOutputDeviceDefault'), + osDefaultAudioDeviceId, + t('settings.audioOutputDeviceOsDefaultNow'), + audioOutputDevice, + t('settings.audioOutputDeviceNotInCurrentList'), + )} + /> + +
+ + )} +
); diff --git a/src/components/settings/audio/NormalizationBlock.tsx b/src/components/settings/audio/NormalizationBlock.tsx index 877d102c..f169d647 100644 --- a/src/components/settings/audio/NormalizationBlock.tsx +++ b/src/components/settings/audio/NormalizationBlock.tsx @@ -4,6 +4,7 @@ import type { TFunction } from 'i18next'; import { useAuthStore } from '../../../store/authStore'; import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from '../../../store/authStoreDefaults'; import { LoudnessLufsButtonGroup } from '../LoudnessLufsButtonGroup'; +import { SettingsGroup } from '../SettingsGroup'; interface Props { preAnalysisEffectiveDb: number; @@ -28,9 +29,10 @@ export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) { const auth = useAuthStore(); return ( -
-
{t('settings.normalization', { defaultValue: 'Normalization' })}
-
{t('settings.normalizationDesc')}
+
)} -
+ ); } diff --git a/src/components/settings/audio/PlaybackBehaviorBlock.tsx b/src/components/settings/audio/PlaybackBehaviorBlock.tsx index f0e78a55..b10aeafc 100644 --- a/src/components/settings/audio/PlaybackBehaviorBlock.tsx +++ b/src/components/settings/audio/PlaybackBehaviorBlock.tsx @@ -6,6 +6,8 @@ import { setTransitionMode, type TransitionMode, } from '../../../utils/playback/playbackTransition'; +import { SettingsGroup } from '../SettingsGroup'; +import { SettingsToggle } from '../SettingsToggle'; interface Props { t: TFunction; @@ -35,10 +37,7 @@ export function PlaybackBehaviorBlock({ t }: Props) { return ( <> -
-
{t('settings.transitionsTitle')}
-
{t('settings.transitionsDesc')}
- +
{transitions.map(item => (
+
-
-
{t('settings.queueBehaviourTitle')}
- -
-
-
- {t('settings.preservePlayNextOrder')} -
-
- {t('settings.preservePlayNextOrderDesc')} -
-
- -
-
+ + + ); } diff --git a/src/components/settings/audio/PlaybackRateBlock.tsx b/src/components/settings/audio/PlaybackRateBlock.tsx index c81e4c64..20b607e9 100644 --- a/src/components/settings/audio/PlaybackRateBlock.tsx +++ b/src/components/settings/audio/PlaybackRateBlock.tsx @@ -22,6 +22,7 @@ import { usePlaybackRateStore } from '../../../store/playbackRateStore'; import { useOrbitStore } from '../../../store/orbitStore'; import { useAuthStore } from '../../../store/authStore'; import { isOrbitPlaybackSyncActive } from '../../../utils/orbit'; +import { SettingsToggle } from '../SettingsToggle'; interface Props { t: TFunction; @@ -109,22 +110,12 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) { onWheel={compact ? handleWheelSpeed : undefined} > {showEnable && ( -
-
-
{t('settings.playbackRateEnabled')}
-
- {t('settings.playbackRateEnabledDesc')} -
-
- -
+ )} {(!showEnable || enabled) && ( diff --git a/src/components/settings/audio/TrackPreviewsSection.tsx b/src/components/settings/audio/TrackPreviewsSection.tsx index 4d407df2..bf836a4c 100644 --- a/src/components/settings/audio/TrackPreviewsSection.tsx +++ b/src/components/settings/audio/TrackPreviewsSection.tsx @@ -5,6 +5,8 @@ import { useAuthStore } from '../../../store/authStore'; import { TRACK_PREVIEW_LOCATIONS } from '../../../store/authStoreDefaults'; import type { TrackPreviewLocation } from '../../../store/authStoreTypes'; import SettingsSubSection from '../../SettingsSubSection'; +import { SettingsGroup } from '../SettingsGroup'; +import { SettingsToggle } from '../SettingsToggle'; interface Props { t: TFunction; @@ -26,103 +28,96 @@ export function TrackPreviewsSection({ t }: Props) { icon={} >
-
-
-
- {t('settings.trackPreviewsToggle')} -
-
- {t('settings.trackPreviewsDesc')} -
-
- -
+ + - {auth.trackPreviewsEnabled && ( - <> -
-
-
- {t('settings.trackPreviewLocationsTitle')} -
-
- {t('settings.trackPreviewLocationsDesc')} -
-
- {TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => ( -
-
- {t(`settings.trackPreviewLocation_${loc}`)} + {auth.trackPreviewsEnabled && ( + <> +
+
+
+ {t('settings.trackPreviewLocationsTitle')} +
+
+ {t('settings.trackPreviewLocationsDesc')} +
+
+ {TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => ( +
+
+ {t(`settings.trackPreviewLocation_${loc}`)} +
+
- -
- ))} + ))} +
-
-
-
-
- {t('settings.trackPreviewStart')} +
+
+
+ {t('settings.trackPreviewStart')} +
+
+ {t('settings.trackPreviewStartDesc')} +
+
+ auth.setTrackPreviewStartRatio(parseFloat(e.target.value))} + style={{ flex: 1, minWidth: 80, maxWidth: 240 }} + aria-label={t('settings.trackPreviewStart')} + /> + + {Math.round(auth.trackPreviewStartRatio * 100)}% + +
-
- {t('settings.trackPreviewStartDesc')} -
-
- auth.setTrackPreviewStartRatio(parseFloat(e.target.value))} - style={{ flex: 1, minWidth: 80, maxWidth: 240 }} - aria-label={t('settings.trackPreviewStart')} - /> - - {Math.round(auth.trackPreviewStartRatio * 100)}% - -
-
-
-
-
- {t('settings.trackPreviewDuration')} +
+
+
+ {t('settings.trackPreviewDuration')} +
+
+ {t('settings.trackPreviewDurationDesc')} +
+
+ auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))} + style={{ flex: 1, minWidth: 80, maxWidth: 240 }} + aria-label={t('settings.trackPreviewDuration')} + /> + + {t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })} + +
-
- {t('settings.trackPreviewDurationDesc')} -
-
- auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))} - style={{ flex: 1, minWidth: 80, maxWidth: 240 }} - aria-label={t('settings.trackPreviewDuration')} - /> - - {t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })} - -
-
- - )} + + )} +
); diff --git a/src/components/settings/musicNetwork/ConnectProviderForm.tsx b/src/components/settings/musicNetwork/ConnectProviderForm.tsx index fbbc0f49..60df9ccd 100644 --- a/src/components/settings/musicNetwork/ConnectProviderForm.tsx +++ b/src/components/settings/musicNetwork/ConnectProviderForm.tsx @@ -71,13 +71,13 @@ export function ConnectProviderForm({ return (
-
{t('musicNetwork.addService')}
+
{t('musicNetwork.addService')}
{available.map(preset => { const id = preset.manifest.presetId; const isExpanded = expanded === id; const isBusy = busy === id; return ( -
+
diff --git a/src/components/settings/musicNetwork/EnrichmentPrimarySelect.tsx b/src/components/settings/musicNetwork/EnrichmentPrimarySelect.tsx index 511e50fc..325736fe 100644 --- a/src/components/settings/musicNetwork/EnrichmentPrimarySelect.tsx +++ b/src/components/settings/musicNetwork/EnrichmentPrimarySelect.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import CustomSelect from '../../CustomSelect'; +import { SettingsGroup } from '../SettingsGroup'; import type { Account } from '../../../music-network'; /** @@ -26,28 +27,24 @@ export function EnrichmentPrimarySelect({ ]; return ( -
-
-
{t('musicNetwork.primaryLabel')}
-
{t('musicNetwork.primaryDesc')}
+ +
+
{t('musicNetwork.primaryDesc')}
+ onChange(v || null)} + style={{ minWidth: 180 }} + />
- onChange(v || null)} - style={{ minWidth: 180 }} - /> -
+ ); } diff --git a/src/components/settings/musicNetwork/MusicNetworkSection.tsx b/src/components/settings/musicNetwork/MusicNetworkSection.tsx index 1d83971a..4d847c24 100644 --- a/src/components/settings/musicNetwork/MusicNetworkSection.tsx +++ b/src/components/settings/musicNetwork/MusicNetworkSection.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Share2 } from 'lucide-react'; import SettingsSubSection from '../../SettingsSubSection'; +import { SettingsGroup } from '../SettingsGroup'; import { showToast } from '../../../utils/ui/toast'; import { useAuthStore } from '../../../store/authStore'; import { @@ -77,38 +78,24 @@ export function MusicNetworkSection() { {t('musicNetwork.desc')}

-
-
-
{t('musicNetwork.masterToggle')}
-
{t('musicNetwork.masterToggleDesc')}
+ +
+
{t('musicNetwork.masterToggleDesc')}
+
- -
+ -
- -
+ {accounts.length > 0 && ( - <> -
+
{accounts.map(account => ( ))}
- - +
)} -
- +
+ +
); diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 561dd0cc..1aae04ba 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -138,6 +138,11 @@ export const settings = { eqAutoEqRateLimit: 'GitHub Rate-Limit erreicht — in einer Minute erneut versuchen', eqAutoEqFetchError: 'EQ-Profil konnte nicht geladen werden', behavior: 'App-Verhalten', + groupTray: 'Tray', + groupLinuxRendering: 'Linux-Rendering', + groupClock: 'Uhr', + groupDisplay: 'Anzeige', + groupWindow: 'Fenster', cacheTitle: 'Max. Speichergröße', cacheDesc: 'Browser-Cache für Cover und Künstlerbilder (IndexedDB). Wenn voll, werden die ältesten Einträge automatisch entfernt.', cacheUsedImages: 'Bilder:', @@ -207,7 +212,7 @@ export const settings = { audioOutputDeviceMacNotice: 'Auf macOS folgt die Wiedergabe aus technischen Gründen zurzeit immer dem System-Ausgabegerät. Du kannst das Ziel über Systemeinstellungen → Ton oder das Lautsprecher-Symbol in der Menüleiste wechseln. Hintergrund: CoreAudio löst beim Öffnen eines nicht-Default-Streams eine Mikrofonberechtigungsabfrage aus — wir vermeiden sie, indem wir stets den System-Default verwenden.', hiResTitle: 'Native Hi-Res-Wiedergabe', hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren', - hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.", + hiResDesc: "Spielt jeden Titel mit seiner ursprünglichen Abtastrate ab, statt alles auf 44,1 kHz herunterzurechnen, und stellt das Ausgabegerät passend zur Datei um (88,2 kHz und höher). Nur aktivieren, wenn Hardware und Netzwerk hohe Abtastraten zuverlässig verarbeiten.", showArtistImages: 'Künstlerbilder anzeigen', showArtistImagesDesc: 'Lädt und zeigt Künstlerbilder in der Künstlerübersicht. Standardmäßig deaktiviert, um Server-I/O und Netzwerklast bei großen Bibliotheken zu reduzieren.', showOrbitTrigger: '„Orbit" im Header zeigen', @@ -249,8 +254,8 @@ export const settings = { linuxWaylandTextRenderSharp: 'Scharf (CPU-freundlich)', linuxWaylandTextRenderGpu: 'GPU zuerst', linuxWaylandTextRenderMinimal: 'Minimal (Standard-CSS-Glättung)', - discordCoverSource: 'Cover-Quelle', - discordCoverSourceDesc: 'Woher das Album-Cover für dein Discord-Profil geladen wird.', + discordCoverTitle: 'Cover-Quelle', + discordCoverDesc: 'Woher das Cover auf deinem Discord-Profil stammt.', discordCoverNone: 'Keine (nur App-Symbol)', discordCoverServer: 'Server (über Album-Info)', discordCoverApple: 'Apple Music', @@ -314,7 +319,6 @@ export const settings = { libraryIndexServerListTitle: 'Indizierte Server', libraryIndexAllExcluded: 'Alle Server sind von der Synchronisation ausgeschlossen. Index wieder aktivieren oder Server hinzufügen.', libraryIndexServerOffline: 'Server offline — Synchronisation verschoben', - libraryIndexServerDeferred: 'Verschoben', libraryIndexServerSyncing: 'Synchronisiert…', libraryIndexFullResync: 'Vollständige Neusynchronisation', libraryIndexDeltaSync: 'Delta-Synchronisation', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index ea3ebbbf..2bc47b50 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -138,6 +138,11 @@ export const settings = { eqAutoEqRateLimit: 'GitHub rate limit reached — try again in a minute', eqAutoEqFetchError: 'Failed to fetch EQ profile', behavior: 'App Behavior', + groupTray: 'Tray', + groupLinuxRendering: 'Linux rendering', + groupClock: 'Clock', + groupDisplay: 'Display', + groupWindow: 'Window', cacheTitle: 'Max. Storage Size', cacheDesc: 'In-browser cache for cover art and artist images (IndexedDB). Oldest entries are removed automatically when space runs low.', cacheUsedImages: 'Images:', @@ -210,7 +215,7 @@ export const settings = { audioOutputDeviceMacNotice: 'On macOS, playback currently always follows the system output device for technical reasons. Change the target via System Settings → Sound or the speaker icon in the menu bar. Background: CoreAudio triggers a microphone-permission prompt when opening a non-default stream — we avoid it by always using the system default.', hiResTitle: 'Native Hi-Res Playback', hiResEnabled: 'Enable native hi-res playback', - hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).", + hiResDesc: "Plays each track at its original sample rate instead of resampling everything to 44.1 kHz, switching the audio device to match the file (88.2 kHz and up). Enable only if your hardware and network reliably handle high sample rates.", showArtistImages: 'Show Artist Images', showArtistImagesDesc: 'Load and display artist images in the Artists overview. Disabled by default to reduce server disk I/O and network load on large libraries.', showOrbitTrigger: 'Show "Orbit" in the header', @@ -252,8 +257,8 @@ export const settings = { linuxWaylandTextRenderSharp: 'Sharp (CPU-friendly)', linuxWaylandTextRenderGpu: 'GPU-first', linuxWaylandTextRenderMinimal: 'Minimal (default CSS smoothing)', - discordCoverSource: 'Cover art source', - discordCoverSourceDesc: 'Where to fetch album artwork shown on your Discord profile.', + discordCoverTitle: 'Cover art source', + discordCoverDesc: 'Where the album artwork on your Discord profile comes from.', discordCoverNone: 'None (app icon only)', discordCoverServer: 'Server (via album info)', discordCoverApple: 'Apple Music', @@ -317,7 +322,6 @@ export const settings = { libraryIndexServerListTitle: 'Indexed servers', libraryIndexAllExcluded: 'All servers are excluded from sync. Re-enable the index or add a server.', libraryIndexServerOffline: 'Server offline — sync deferred', - libraryIndexServerDeferred: 'Deferred', libraryIndexServerSyncing: 'Syncing…', libraryIndexFullResync: 'Full resync', libraryIndexDeltaSync: 'Delta sync', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 83bdc0e9..f0790ad3 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -137,6 +137,11 @@ export const settings = { eqAutoEqRateLimit: 'Límite de GitHub alcanzado — intenta de nuevo en un minuto', eqAutoEqFetchError: 'Error al obtener perfil EQ', behavior: 'Comportamiento de la App', + groupTray: 'Bandeja', + groupLinuxRendering: 'Renderizado Linux', + groupClock: 'Reloj', + groupDisplay: 'Visualización', + groupWindow: 'Ventana', cacheTitle: 'Tamaño Máx. de Almacenamiento', cacheDesc: 'Caché del navegador para portadas e imágenes de artistas (IndexedDB). Cuando está lleno, las entradas más antiguas se eliminan automáticamente.', cacheUsedImages: 'Imágenes:', @@ -206,7 +211,7 @@ export const settings = { audioOutputDeviceMacNotice: 'En macOS, la reproducción actualmente sigue siempre al dispositivo de salida del sistema por razones técnicas. Cambia el destino mediante Ajustes del Sistema → Sonido o el icono del altavoz en la barra de menús. Motivo: CoreAudio activa una solicitud de permiso de micrófono al abrir un stream no-predeterminado — lo evitamos usando siempre la salida por defecto del sistema.', hiResTitle: 'Reproducción Nativa Hi-Res', hiResEnabled: 'Habilitar reproducción nativa hi-res', - hiResDesc: "Fuerza 44.1 kHz de salida por defecto para máxima estabilidad. Habilita solo si tu hardware y red soportan confiablemente altas tasas de muestreo (88.2 kHz+).", + hiResDesc: "Reproduce cada pista a su frecuencia de muestreo original en vez de remuestrear todo a 44.1 kHz, ajustando el dispositivo de salida para que coincida con el archivo (88.2 kHz o más). Habilítalo solo si tu hardware y tu red soportan de forma fiable altas tasas de muestreo.", showArtistImages: 'Mostrar Imágenes de Artistas', showArtistImagesDesc: 'Carga y muestra imágenes de artistas en el resumen de Artistas. Desactivado por defecto para reducir I/O de disco del servidor y carga de red en bibliotecas grandes.', showOrbitTrigger: 'Mostrar "Orbit" en la cabecera', @@ -248,8 +253,8 @@ export const settings = { linuxWaylandTextRenderSharp: 'Nítido (más CPU)', linuxWaylandTextRenderGpu: 'Prioridad GPU', linuxWaylandTextRenderMinimal: 'Mínimo (suavizado CSS por defecto)', - discordCoverSource: 'Fuente de portada', - discordCoverSourceDesc: 'De dónde obtener la portada del álbum para tu perfil de Discord.', + discordCoverTitle: 'Fuente de la carátula', + discordCoverDesc: 'De dónde proviene la carátula mostrada en tu perfil de Discord.', discordCoverNone: 'Ninguna (solo icono de la app)', discordCoverServer: 'Servidor (vía info del álbum)', discordCoverApple: 'Apple Music', @@ -312,7 +317,6 @@ export const settings = { libraryIndexServerListTitle: 'Servidores indexados', libraryIndexAllExcluded: 'Todos los servidores están excluidos de la sincronización. Vuelve a activar el índice o añade un servidor.', libraryIndexServerOffline: 'Servidor sin conexión — sincronización aplazada', - libraryIndexServerDeferred: 'Aplazada', libraryIndexServerSyncing: 'Sincronizando…', libraryIndexFullResync: 'Resincronización completa', libraryIndexDeltaSync: 'Sincronización delta', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 80cfc75a..baaed3a5 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -137,6 +137,11 @@ export const settings = { eqAutoEqRateLimit: 'Limite GitHub atteinte — réessayez dans une minute', eqAutoEqFetchError: 'Impossible de charger le profil EQ', behavior: 'Comportement de l\'application', + groupTray: 'Zone de notification', + groupLinuxRendering: 'Rendu Linux', + groupClock: 'Horloge', + groupDisplay: 'Affichage', + groupWindow: 'Fenêtre', cacheTitle: 'Taille max. du stockage', cacheDesc: 'Cache navigateur pour pochettes et images d\'artistes (IndexedDB). Quand il est plein, les entrées les plus anciennes sont supprimées automatiquement.', cacheUsedImages: 'Images :', @@ -206,7 +211,7 @@ export const settings = { audioOutputDeviceMacNotice: 'Sur macOS, la lecture suit actuellement toujours la sortie audio du système pour des raisons techniques. Changez la cible via Réglages Système → Son ou l\'icône haut-parleur de la barre de menus. Contexte : CoreAudio déclenche une demande d\'autorisation du microphone à l\'ouverture d\'un flux non par défaut — nous l\'évitons en utilisant toujours la sortie système par défaut.', hiResTitle: 'Lecture haute résolution native', hiResEnabled: 'Activer la lecture haute résolution native', - hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).", + hiResDesc: "Lit chaque piste à sa fréquence d'échantillonnage d'origine au lieu de tout rééchantillonner à 44,1 kHz, en réglant le périphérique de sortie sur celle du fichier (88,2 kHz et plus). N'activer que si le matériel et le réseau gèrent ces hautes fréquences de façon fiable.", showArtistImages: 'Afficher les images d\'artistes', showArtistImagesDesc: 'Charge et affiche les images d\'artistes dans la vue d\'ensemble. Désactivé par défaut pour réduire les E/S disque serveur et la charge réseau sur les grandes bibliothèques.', showOrbitTrigger: 'Afficher « Orbit » dans l\'en-tête', @@ -236,8 +241,8 @@ export const settings = { discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.', discordRichPresenceNotice: 'Attention : il s\'agit de la Discord Rich Presence intégrée à Psysonic. Si vous préférez utiliser le plugin officiel Discord Rich Presence de Navidrome, laissez cette fonction désactivée et activez plutôt « Afficher dans la fenêtre live » plus bas sur cette page.', - discordCoverSource: 'Source de pochette', - discordCoverSourceDesc: 'D\'où récupérer la pochette pour votre profil Discord.', + discordCoverTitle: 'Source de la pochette', + discordCoverDesc: 'D\'où provient la pochette affichée sur votre profil Discord.', discordCoverNone: 'Aucune (icône de l\'app uniquement)', discordCoverServer: 'Serveur (via infos album)', discordCoverApple: 'Apple Music', @@ -300,7 +305,6 @@ export const settings = { libraryIndexServerListTitle: 'Serveurs indexés', libraryIndexAllExcluded: 'Tous les serveurs sont exclus de la synchronisation. Réactivez l’index ou ajoutez un serveur.', libraryIndexServerOffline: 'Serveur hors ligne — synchronisation reportée', - libraryIndexServerDeferred: 'Reportée', libraryIndexServerSyncing: 'Synchronisation…', libraryIndexFullResync: 'Resynchronisation complète', libraryIndexDeltaSync: 'Synchronisation delta', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 19582408..7b302e89 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -137,6 +137,11 @@ export const settings = { eqAutoEqRateLimit: 'GitHub-hastighetsgrense nådd - prøv igjen om ett minutt', eqAutoEqFetchError: 'Kunne ikke hente EQ-profil', behavior: 'App-oppførsel', + groupTray: 'Systemkurv', + groupLinuxRendering: 'Linux-gjengivelse', + groupClock: 'Klokke', + groupDisplay: 'Visning', + groupWindow: 'Vindu', cacheTitle: 'Maks. lagringsstørrelse', cacheDesc: 'Nettlesercache for plateomslag og artistbilder (IndexedDB). Når den er full, fjernes de eldste oppføringene automatisk.', cacheUsed: 'Brukt: {{images}} bilder · {{offline}} frakoblede spor', @@ -207,7 +212,7 @@ export const settings = { audioOutputDeviceMacNotice: 'På macOS følger avspillingen av tekniske årsaker alltid systemets lydutgang. Endre målet via Systeminnstillinger → Lyd eller høyttalerikonet i menylinjen. Bakgrunn: CoreAudio utløser en mikrofontillatelsesdialog når en ikke-standard strøm åpnes — vi unngår det ved alltid å bruke systemets standardutgang.', hiResTitle: 'Innebygd hi-res-avspilling', hiResEnabled: 'Aktiver innebygd hi-res-avspilling', - hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.", + hiResDesc: "Spiller hvert spor med sin opprinnelige samplingsrate i stedet for å nedsample alt til 44,1 kHz, og stiller utdataenheten etter filen (88,2 kHz og høyere). Aktiver kun hvis maskinvare og nettverk håndterer høye samplingsrater pålitelig.", showArtistImages: 'Vis artistbilder', showArtistImagesDesc: 'Last inn og vis artistbilder i artistoversikten. Denne er deaktivert som standard, for å redusere disk-I/O og nettverksbelastningen på store biblioteker.', showOrbitTrigger: 'Vis «Orbit» i toppen', @@ -235,8 +240,8 @@ export const settings = { discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.', discordRichPresenceNotice: 'Merk: dette er den innebygde Discord Rich Presence i Psysonic. Vil du heller bruke det offisielle Navidrome Discord Rich Presence-tillegget, la denne funksjonen være av og aktiver i stedet «Vis i "Nå spiller"» lenger ned på denne siden.', - discordCoverSource: 'Coverkilde', - discordCoverSourceDesc: 'Hvor albumcoveret for Discord-profilen din hentes fra.', + discordCoverTitle: 'Omslagskilde', + discordCoverDesc: 'Hvor albumomslaget på Discord-profilen din kommer fra.', discordCoverNone: 'Ingen (kun app-ikon)', discordCoverServer: 'Server (via albuminfo)', discordCoverApple: 'Apple Music', @@ -299,7 +304,6 @@ export const settings = { libraryIndexServerListTitle: 'Indekserte servere', libraryIndexAllExcluded: 'Alle servere er ekskludert fra synkronisering. Aktiver indeksen igjen eller legg til en server.', libraryIndexServerOffline: 'Server offline — synkronisering utsatt', - libraryIndexServerDeferred: 'Utsatt', libraryIndexServerSyncing: 'Synkroniserer…', libraryIndexFullResync: 'Full resynkronisering', libraryIndexDeltaSync: 'Delta-synkronisering', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 6fdf3b02..6d9dccb7 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -137,6 +137,11 @@ export const settings = { eqAutoEqRateLimit: 'GitHub limiet bereikt — probeer over een minuut opnieuw', eqAutoEqFetchError: 'EQ-profiel kon niet worden geladen', behavior: 'App-gedrag', + groupTray: 'Systeemvak', + groupLinuxRendering: 'Linux-rendering', + groupClock: 'Klok', + groupDisplay: 'Weergave', + groupWindow: 'Venster', cacheTitle: 'Max. opslaggrootte', cacheDesc: 'Browsercache voor albumhoezen en artiestafbeeldingen (IndexedDB). Als de cache vol is, worden de oudste items automatisch verwijderd.', cacheUsedImages: 'Afbeeldingen:', @@ -206,7 +211,7 @@ export const settings = { audioOutputDeviceMacNotice: 'Op macOS volgt de weergave om technische redenen altijd het standaard-uitvoerapparaat van het systeem. Wijzig het doel via Systeeminstellingen → Geluid of via het luidsprekerpictogram in de menubalk. Achtergrond: CoreAudio vraagt bij het openen van een niet-standaard stream om microfoontoestemming — dat vermijden we door altijd de systeemstandaard te gebruiken.', hiResTitle: 'Natieve hi-res-weergave', hiResEnabled: 'Natieve hi-res-weergave inschakelen', - hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.", + hiResDesc: "Speelt elke track af op zijn oorspronkelijke samplerate in plaats van alles te herbemonsteren naar 44,1 kHz, en stelt het uitvoerapparaat af op het bestand (88,2 kHz en hoger). Alleen inschakelen als hardware en netwerk hoge samplerates betrouwbaar aankunnen.", showArtistImages: 'Artiestafbeeldingen weergeven', showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.', showOrbitTrigger: '"Orbit" in de kop tonen', @@ -236,8 +241,8 @@ export const settings = { discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.', discordRichPresenceNotice: 'Let op: dit is de in Psysonic ingebouwde Discord Rich Presence. Wil je liever de officiële Navidrome Discord Rich Presence-plug-in gebruiken, laat deze functie dan uitgeschakeld en schakel in plaats daarvan verderop op deze pagina „Weergeven in live-venster" in.', - discordCoverSource: 'Hoesbron', - discordCoverSourceDesc: 'Waar de albumhoes voor je Discord-profiel vandaan komt.', + discordCoverTitle: 'Bron albumhoes', + discordCoverDesc: 'Waar de albumhoes op je Discord-profiel vandaan komt.', discordCoverNone: 'Geen (alleen app-icoon)', discordCoverServer: 'Server (via albuminfo)', discordCoverApple: 'Apple Music', @@ -300,7 +305,6 @@ export const settings = { libraryIndexServerListTitle: 'Geïndexeerde servers', libraryIndexAllExcluded: 'Alle servers zijn uitgesloten van synchronisatie. Schakel de index weer in of voeg een server toe.', libraryIndexServerOffline: 'Server offline — synchronisatie uitgesteld', - libraryIndexServerDeferred: 'Uitgesteld', libraryIndexServerSyncing: 'Synchroniseren…', libraryIndexFullResync: 'Volledige resync', libraryIndexDeltaSync: 'Delta-sync', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index ee3b585d..fe9658a4 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -137,6 +137,11 @@ export const settings = { eqAutoEqRateLimit: 'Limita ratei GitHub a fost atinsă — încearcă din nou într-un minut', eqAutoEqFetchError: 'Nu s-a putut lua profilul EQ', behavior: 'Comportamentul Aplicației', + groupTray: 'Bară de sistem', + groupLinuxRendering: 'Randare Linux', + groupClock: 'Ceas', + groupDisplay: 'Afișare', + groupWindow: 'Fereastră', cacheTitle: 'Spațiu de stocare maxim', cacheDesc: 'Cache în browser pentru coperte și imagini de artiști (IndexedDB). Când este plin, cele mai vechi intrări sunt șterse automat.', cacheUsedImages: 'Imagini:', @@ -209,7 +214,7 @@ export const settings = { audioOutputDeviceMacNotice: 'În macOS, redarea urmărește mereu dispozitivul audio de ieșire al sistemului din motive tehnice. Schimbă ținta în Setări → Sunet sau iconița difuzor din bara de meniu. Fundal: CoreAudio declanșează un prompt de permisiune de microfon când se deschide un stream non-implicit — îl evităm folosind opțiunea implicită a sistemului mereu.', hiResTitle: 'Playback Hi-Res nativ', hiResEnabled: 'Pornește playback-ul hi-res nativ', - hiResDesc: "Forțează ieșire implicită de 44.1 kHz pentru stabilitate maximă. Pornește doar dacă sistemul hardware și rețeaua suport fiabil rate mari de eșantionare (88.2 kHz+).", + hiResDesc: "Redă fiecare piesă la rata sa de eșantionare originală în loc să reeșantioneze totul la 44.1 kHz, comutând dispozitivul de ieșire pentru a se potrivi cu fișierul (88.2 kHz și peste). Pornește doar dacă hardware-ul și rețeaua gestionează fiabil rate mari de eșantionare.", showArtistImages: 'Afișează Imagini Artist', showArtistImagesDesc: 'Încarcă și afișează imagini artist în Prezentarea generală a Artiștilor. Oprit implicit pentru a reduce I/O pe server și încărcarea rețelei pe librării mari.', showOrbitTrigger: 'Afișează "Orbit" în antet', @@ -251,8 +256,8 @@ export const settings = { linuxWaylandTextRenderSharp: 'Ascuțit (prietenos cu CPU)', linuxWaylandTextRenderGpu: 'Prioritate GPU', linuxWaylandTextRenderMinimal: 'Minim (netezire CSS implicită)', - discordCoverSource: 'Sursa artei de copertă', - discordCoverSourceDesc: 'De unde să fie preluată arta de album afișată pe profilul tău de Discord.', + discordCoverTitle: 'Sursa coperții', + discordCoverDesc: 'De unde provine coperta afișată pe profilul tău Discord.', discordCoverNone: 'Niciuna (doar iconița aplicației)', discordCoverServer: 'Server (prin informații album)', discordCoverApple: 'Apple Music', @@ -316,7 +321,6 @@ export const settings = { libraryIndexServerListTitle: 'Servere indexate', libraryIndexAllExcluded: 'Toate serverele sunt excluse din sincronizare. Reactivează indexul sau adaugă un server.', libraryIndexServerOffline: 'Server offline — sincronizare amânată', - libraryIndexServerDeferred: 'Amânată', libraryIndexServerSyncing: 'Se sincronizează…', libraryIndexFullResync: 'Resincronizare completă', libraryIndexDeltaSync: 'Sincronizare delta', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 717a0a71..826e3251 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -137,6 +137,11 @@ export const settings = { eqAutoEqRateLimit: 'Лимит GitHub — попробуйте через минуту', eqAutoEqFetchError: 'Не удалось загрузить профиль EQ', behavior: 'Поведение', + groupTray: 'Трей', + groupLinuxRendering: 'Отрисовка (Linux)', + groupClock: 'Часы', + groupDisplay: 'Отображение', + groupWindow: 'Окно', cacheTitle: 'Макс. размер кэша', cacheDesc: 'Кэш обложек и фото исполнителей в хранилище приложения (IndexedDB). При нехватке места старые записи удаляются автоматически.', @@ -210,7 +215,7 @@ export const settings = { audioOutputDeviceMacNotice: 'В macOS воспроизведение в настоящее время всегда следует за системным устройством вывода по техническим причинам. Измените цель через Системные настройки → Звук или значок динамика в строке меню. Причина: CoreAudio запрашивает разрешение на использование микрофона при открытии не-стандартного потока — мы избегаем этого, всегда используя системный вывод по умолчанию.', hiResTitle: 'Нативное воспроизведение Hi-Res', hiResEnabled: 'Включить нативное Hi-Res воспроизведение', - hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).", + hiResDesc: "Воспроизводит каждый трек с его исходной частотой дискретизации вместо передискретизации всего в 44,1 кГц, переключая устройство вывода под частоту файла (88,2 кГц и выше). Включайте только если оборудование и сеть надёжно справляются с высокими частотами.", showArtistImages: 'Фото исполнителей', showArtistImagesDesc: 'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.', @@ -255,8 +260,8 @@ export const settings = { discordRichPresenceDesc: 'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.', discordRichPresenceNotice: 'Внимание: это встроенная Discord Rich Presence в Psysonic. Если вы хотите использовать официальный плагин Discord Rich Presence для Navidrome, оставьте эту функцию выключенной и вместо этого включите «Показывать в „Сейчас играет"» ниже на этой странице.', - discordCoverSource: 'Источник обложки', - discordCoverSourceDesc: 'Откуда загружать обложку альбома для профиля Discord.', + discordCoverTitle: 'Источник обложки', + discordCoverDesc: 'Откуда берётся обложка, показываемая в вашем профиле Discord.', discordCoverNone: 'Нет (только иконка приложения)', discordCoverServer: 'Сервер (через информацию об альбоме)', discordCoverApple: 'Apple Music', @@ -321,7 +326,6 @@ export const settings = { libraryIndexServerListTitle: 'Индексируемые серверы', libraryIndexAllExcluded: 'Все серверы исключены из синхронизации.', libraryIndexServerOffline: 'Сервер офлайн — синхронизация отложена', - libraryIndexServerDeferred: 'Отложено', libraryIndexServerSyncing: 'Синхронизация…', libraryIndexFullResync: 'Полная пересинхронизация', libraryIndexDeltaSync: 'Быстрая дельта', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 27a3d461..9a08a8ef 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -137,6 +137,11 @@ export const settings = { eqAutoEqRateLimit: 'GitHub 请求限制 — 请一分钟后重试', eqAutoEqFetchError: '无法获取 EQ 配置', behavior: '应用行为', + groupTray: '系统托盘', + groupLinuxRendering: 'Linux 渲染', + groupClock: '时钟', + groupDisplay: '显示', + groupWindow: '窗口', cacheTitle: '最大存储大小', cacheDesc: '浏览器内封面和艺术家图片缓存(IndexedDB)。存满时,最旧的条目将自动移除。', cacheUsedImages: '图片:', @@ -206,7 +211,7 @@ export const settings = { audioOutputDeviceMacNotice: '在 macOS 上,出于技术原因,目前播放始终跟随系统输出设备。请通过 系统设置 → 声音 或菜单栏扬声器图标切换目标设备。背景:打开非默认音频流时 CoreAudio 会触发麦克风权限提示 —— 我们通过始终使用系统默认输出来避免此提示。', hiResTitle: '原生高清晰度播放', hiResEnabled: '启用原生高清晰度播放', - hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。", + hiResDesc: "以每个曲目的原始采样率播放,而不是将所有内容重采样到 44.1 kHz,并将输出设备切换为与文件匹配(88.2 kHz 及以上)。仅在硬件和网络能可靠处理高采样率时启用。", showArtistImages: '显示艺术家图片', showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。', showOrbitTrigger: '在顶栏显示"Orbit"', @@ -235,8 +240,8 @@ export const settings = { discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。', discordRichPresenceNotice: '注意:这是 Psysonic 内置的 Discord Rich Presence。如果你想使用官方的 Navidrome Discord Rich Presence 插件,请保持此功能关闭,并改为启用本页下方的"在实时窗口中显示"。', - discordCoverSource: '封面来源', - discordCoverSourceDesc: '从何处获取 Discord 个人资料上显示的专辑封面。', + discordCoverTitle: '封面来源', + discordCoverDesc: '你的 Discord 资料中显示的专辑封面来源。', discordCoverNone: '无(仅显示应用图标)', discordCoverServer: '服务器(通过专辑信息)', discordCoverApple: 'Apple Music', @@ -299,7 +304,6 @@ export const settings = { libraryIndexServerListTitle: '已索引的服务器', libraryIndexAllExcluded: '所有服务器均已排除同步。请重新启用索引或添加服务器。', libraryIndexServerOffline: '服务器离线——同步已推迟', - libraryIndexServerDeferred: '已推迟', libraryIndexServerSyncing: '同步中…', libraryIndexFullResync: '完全重新同步', libraryIndexDeltaSync: '增量同步', diff --git a/src/styles/components/settings.css b/src/styles/components/settings.css index 8d4cc137..8ba044b3 100644 --- a/src/styles/components/settings.css +++ b/src/styles/components/settings.css @@ -544,6 +544,18 @@ span.settings-server-use-active-slot { padding: var(--space-3); } +/* A card whose only content is one boxed group double-frames it (card border + + group border). Collapse the card so single-group sections render just the + group — matching the card-less subsections (e.g. Lyrics). Multi-group cards + keep their frame as the grouping container. The flat Themes sections are + excluded: not being inside an accordion, their card is the contrast surface. */ +.settings-card:has(> .settings-group:only-child):not(.themes-section *) { + background: none; + border: none; + padding: 0; + border-radius: 0; +} + /* Boxed sub-section panel inside a settings card — sets related controls apart as a distinct block (e.g. Normalization / Track transitions / Queue behaviour in the Playback card). */ @@ -559,6 +571,9 @@ span.settings-server-use-active-slot { } .settings-group-title { + display: flex; + align-items: center; + gap: 6px; font-size: 11px; font-weight: 700; letter-spacing: 0.06em; @@ -567,12 +582,23 @@ span.settings-server-use-active-slot { margin-bottom: 6px; } +.settings-group-title-icon { + display: inline-flex; + color: var(--accent); +} + .settings-group-desc { font-size: 12px; color: var(--text-muted); margin-bottom: 0.85rem; } +/* Small consistent left inset for box content (the title/header stays flush + at the box edge; the controls sit slightly indented for breathing room). */ +.settings-group-body { + padding-left: 10px; +} + .settings-card.user-row { transition: background 120ms ease, border-color 120ms ease; } diff --git a/src/styles/layout/queue-panel.css b/src/styles/layout/queue-panel.css index fd4d21ac..51799a34 100644 --- a/src/styles/layout/queue-panel.css +++ b/src/styles/layout/queue-panel.css @@ -236,6 +236,10 @@ color: var(--bg-app); background: var(--accent); } +.queue-round-btn.active:hover:not(:disabled) { + color: var(--bg-app); + background: var(--accent); +} .queue-current-track { display: flex;
{t('settings.imageCacheScopeLabel')}{t('settings.imageCacheScopeLabel')} {t('settings.coverCacheStrategyProgressLabel')} {t('settings.coverCacheStrategyActionsLabel')}
+ {t('settings.imageCacheSubTitle')}
{t('settings.coverCacheStrategyServerLabel')}{t('settings.coverCacheStrategyServerLabel')} {t('settings.coverCacheStrategyLabel')} {t('settings.coverCacheStrategyProgressLabel')} {t('settings.coverCacheStrategyActionsLabel')}
{label}{label}
{COVER_CACHE_STRATEGIES.map(s => ( @@ -338,7 +340,7 @@ export default function CoverCacheStrategySection() { })} {removedServerKeys.map(key => (
+ {key} ({t('settings.coverCacheStrategyServerRemoved')})