refactor(settings): reusable SettingsGroup/SettingsToggle + boxed sections across tabs (#1126)

* refactor(settings): extract reusable SettingsGroup component

Pull the boxed sub-section pattern (bordered panel + accent uppercase
header) introduced for the Audio tab into a reusable <SettingsGroup
title desc> component, and migrate NormalizationBlock and
PlaybackBehaviorBlock onto it. No visual change.

* feat(settings): box the System behavior section into groups

Split the System -> App Behavior card (which bundled tray toggles, Linux
rendering tweaks and the clock format) into titled SettingsGroup panels
(Tray / Linux rendering / Clock) for a clearer, consistent boxed look.
Adds the group titles across all 9 locales.

* feat(settings): box Appearance visual options; optional SettingsGroup title

Split the Appearance -> Visual options card into a Display group and a
Window group (Linux custom titlebar controls). SettingsGroup now allows an
optional/omitted title for plain boxed panels. Group titles added across
all 9 locales.

* feat(settings): box Discord cover source and templates

Group the Discord cover-source toggles and the activity templates into
separate SettingsGroup panels (templates reuse their existing title/desc),
dropping the manual indent and inline header. No new strings.

* feat(settings): box the Music Network section

Give the scrobble-master toggle, the enrichment-primary picker and the
provider list each a boxed SettingsGroup with an accent header, and switch
the add-a-service provider rows to the boxed panel style for consistent
contrast with the rest of settings.

* feat(settings): titled boxes for Integrations sections + cover source label

Wrap the Discord enable toggle, the Discord cover-source picker (now with a
"Cover art source" title and explainer), Bandsintown and Show-in-Now-Playing
in titled SettingsGroup panels so each single-item section gets the same
accent-headed boxed look. New cover-source strings across all 9 locales.

* feat(settings): box Lyrics tab sections

Wrap the lyrics-sources customizer and the sidebar-style picker in titled
SettingsGroup panels (reusing the existing section titles).

* refactor(settings): consistent content inset in SettingsGroup

Add a body wrapper with a small left inset so every boxed section indents its
controls uniformly (title stays flush at the box edge). Defined once in the
component instead of per section.

* feat(settings): box Storage tab sections incl. cover art cache

Wrap media directory, cover art cache strategy, next-track buffering and
downloads in titled SettingsGroup panels (reusing existing section titles);
align the cover-cache table's first column flush with the new content inset.

* feat(settings): box Library tab, relocate Lucky Mix, add SettingsToggle

- Add a reusable SettingsToggle component for the repeated label/desc/switch row.
- Box the Random Mix blacklist (grouping built-in keywords with the custom
  filter), Ratings and the Analytics strategy section.
- Move the "Show Lucky Mix in menu" toggle out of the blacklist and up next to
  split-mix navigation and now-playing-at-top in the sidebar customizer.

* feat(settings): box remaining Appearance sections

Wrap library grid, UI scale, font and seekbar style in titled SettingsGroup
panels (reusing the existing section titles).

* feat(settings): box System language, logging and backup sections

Wrap language and logging in titled SettingsGroup panels, and rework the
backup section to drop its redundant inline header (the subsection already
provides it) and box its content. About / Contributors / Licenses stay as
free display content.

* feat(settings): box remaining Audio sections

Wrap Hi-Res, equalizer, playback speed, audio output device and track
previews in titled SettingsGroup panels (reusing the existing section titles).

* feat(settings): box Input tab keybinding sections

Wrap the in-app and global shortcut lists in titled SettingsGroup panels.

* feat(settings): box the Personalisation tab

Convert every customizer (sidebar, home, artist, queue toolbar, playlist,
player bar) to plain inner containers and wrap each in a titled SettingsGroup,
so the boxed look is consistent without nested cards. Box the queue display
mode picker too.

* feat(settings): box the Themes sections above the store

Add a boxed option to the flat Themes sections and apply it to Your Themes,
Auto-Switch Theme and Import (the theme store stays unboxed). Drop the inner
settings-card from InstalledThemes, ThemeImportSection and the scheduler so
the SettingsGroup is the only frame.

* fix(settings): give the Themes boxes a card carrier and accent header

The flat Themes sections had no settings-card behind them, so the bg-app
SettingsGroup had no contrast against the page. Box each above-store section
as settings-card > titled SettingsGroup (accent header) like the rest.

* fix(settings): clean up the server library sync status line

Collapse the offline status to a single icon + phrase ("Server offline —
sync deferred") instead of a redundant "Deferred" badge alongside it, and
drop the now-unused string across all 9 locales.

* fix(queue): keep toolbar toggle buttons coloured active while hovered

The generic .queue-round-btn:hover rule outweighed .active in specificity, so
an active button kept the hover colour until the pointer left — the toggle
only looked on after moving the mouse away. Add an explicit active-hover rule
(mirroring the mini player) so active wins immediately on click.

* fix(settings): fix Lyrics triple title, drop dead Discord cover keys

- Remove LyricsSourcesCustomizer's own section header (the subsection already
  titles it) and make its inner cards plain; the Lyrics boxes are now title-less.
- Drop the dead discordCoverSource/discordCoverSourceDesc i18n keys (superseded
  by discordCoverTitle/Desc) and un-jam the libraryIndexServer status line in
  all 9 locales.

* refactor(settings): drop redundant group titles and double frames

Single-group sections duplicated the subsection title in the group header — make
those groups title-less so the subsection header is the only title. A card whose
sole child is one group now collapses via CSS so single-group sections render a
single frame (matching the card-less Lyrics layout) instead of a card-in-group
double border. Multi-group cards keep their frame as the grouping container.

* fix(settings): keep the flat Themes cards framed when collapsing single-group cards

The single-group card collapse also flattened the Themes section cards, which —
not being inside an accordion — are themselves the contrast surface. Exclude
.themes-section descendants from the collapse.

* refactor(settings): use SettingsToggle for Appearance and System toggle rows

Replace the hand-rolled toggle-row markup in the Appearance visual options and
System tray/Linux/changelog rows with the shared SettingsToggle component, and
extend it with a searchText prop and ReactNode descriptions for the remaining
call sites.

* refactor(settings): roll SettingsToggle out to Integrations, Audio and Storage

Replace hand-rolled toggle rows in the Discord/Bandsintown/Now-Playing,
Hi-Res, hot cache and playback behaviour/rate sections with SettingsToggle.
Make its label optional (desc-only rows whose title is the group header) and
add searchText/id pass-throughs.

* refactor(settings): use SettingsToggle for the remaining standard toggle rows

Track previews master, sidebar lyrics style, YouLyPlus/static-only, queue
display mode and the sidebar split-nav/now-playing/lucky-mix rows now use the
shared component. Genuinely custom rows (drag lists, the skip-star threshold,
the AudioMuse row with its inline link, the fine-step advanced badge) stay
hand-rolled.

* fix(settings): drop duplicate titles on Hi-Res/Bandsintown/Now-Playing, un-jam locale line

These single-toggle sections kept a group title identical to their subsection
header; once the single-group card collapses the title rendered twice stacked.
Make them title-less (the subsection header is the label). Also split the
discordCoverTitle locale entry onto its own line in all 9 locales.

* feat(settings): restore section icons in boxed Themes headers

SettingsGroup gained an optional accent icon slot rendered before the
title; ThemesTab forwards the Palette/Clock/Upload icons the boxed
sections lost when they moved off the bespoke <h2> header.

* style(settings): re-indent SettingsGroup children to nesting level

Pure whitespace: children wrapped in SettingsGroup were left at their
pre-wrap indent. No content change (git diff -w is empty).

* fix(settings): clarify Native Hi-Res Playback description

The old copy led with the disabled-state behaviour ("forces 44.1 kHz"),
which read as if the toggle itself locked output to 44.1 kHz. Describe
what enabling actually does: play each track at its native sample rate
(reconfiguring the output device to match) instead of resampling to
44.1 kHz. Updated across all 9 locales.

* docs: changelog + what's-new for the settings layout refactor (#1126)
This commit is contained in:
Psychotoxical
2026-06-18 22:24:34 +02:00
committed by GitHub
parent 99c0b6cdac
commit d50c9c444d
46 changed files with 1312 additions and 1308 deletions
+11
View File
@@ -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
+4
View File
@@ -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
@@ -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={<BarChart3 size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
<th style={{ textAlign: 'left', padding: '8px 10px', paddingLeft: 0, fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.analyticsStrategyServerLabel')}
</th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}>
@@ -284,7 +286,7 @@ export default function AnalyticsStrategySection() {
const label = serverListDisplayLabel(server, servers);
return (
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>
{label}
</td>
<td style={{ padding: '10px' }}>
@@ -365,7 +367,7 @@ export default function AnalyticsStrategySection() {
key={serverId}
style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}
>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-secondary)' }}>
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-secondary)' }}>
<div>{serverId}</div>
<div style={{ fontSize: 11, color: 'var(--warning, #f59e0b)', marginTop: 2 }}>
{t('settings.analyticsStrategyServerRemoved')}
@@ -432,6 +434,7 @@ export default function AnalyticsStrategySection() {
{t('settings.analyticsStrategyAdvancedWarning')}
</span>
</div>
</SettingsGroup>
</div>
{clearTarget &&
+67 -95
View File
@@ -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,6 +36,7 @@ export function AppearanceTab() {
icon={<LayoutGrid size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
{t('settings.libraryGridMaxColumnsPerfHint')}
</div>
@@ -66,6 +69,7 @@ export function AppearanceTab() {
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.75rem', lineHeight: 1.45 }}>
{t('settings.libraryGridMaxColumnsDesc')}
</p>
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -74,103 +78,69 @@ export function AppearanceTab() {
icon={<Palette size={16} />}
>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.coverArtBackground')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.coverArtBackgroundSub')}</div>
</div>
<label className="toggle-switch">
<input type="checkbox" checked={theme.enableCoverArtBackground} onChange={e => theme.setEnableCoverArtBackground(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsGroup title={t('settings.groupDisplay')}>
<SettingsToggle
label={t('settings.coverArtBackground')}
desc={t('settings.coverArtBackgroundSub')}
checked={theme.enableCoverArtBackground}
onChange={theme.setEnableCoverArtBackground}
/>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.playlistCoverPhoto')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.playlistCoverPhotoSub')}</div>
</div>
<label className="toggle-switch">
<input type="checkbox" checked={theme.enablePlaylistCoverPhoto} onChange={e => theme.setEnablePlaylistCoverPhoto(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsToggle
label={t('settings.playlistCoverPhoto')}
desc={t('settings.playlistCoverPhotoSub')}
checked={theme.enablePlaylistCoverPhoto}
onChange={theme.setEnablePlaylistCoverPhoto}
/>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showBitrate')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showBitrateSub')}</div>
</div>
<label className="toggle-switch">
<input type="checkbox" checked={theme.showBitrate} onChange={e => theme.setShowBitrate(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsToggle
label={t('settings.showBitrate')}
desc={t('settings.showBitrateSub')}
checked={theme.showBitrate}
onChange={theme.setShowBitrate}
/>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.floatingPlayerBar')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.floatingPlayerBarSub')}</div>
</div>
<label className="toggle-switch">
<input type="checkbox" checked={theme.floatingPlayerBar} onChange={e => theme.setFloatingPlayerBar(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsToggle
label={t('settings.floatingPlayerBar')}
desc={t('settings.floatingPlayerBarSub')}
checked={theme.floatingPlayerBar}
onChange={theme.setFloatingPlayerBar}
/>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showArtistImages')}>
<input type="checkbox" checked={auth.showArtistImages} onChange={e => auth.setShowArtistImages(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsToggle
label={t('settings.showArtistImages')}
desc={t('settings.showArtistImagesDesc')}
checked={auth.showArtistImages}
onChange={auth.setShowArtistImages}
/>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showOrbitTrigger')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showOrbitTriggerDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showOrbitTrigger')}>
<input type="checkbox" checked={auth.showOrbitTrigger} onChange={e => auth.setShowOrbitTrigger(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsToggle
label={t('settings.showOrbitTrigger')}
desc={t('settings.showOrbitTriggerDesc')}
checked={auth.showOrbitTrigger}
onChange={auth.setShowOrbitTrigger}
/>
{!IS_WINDOWS && (
<>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.preloadMiniPlayer')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.preloadMiniPlayerDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.preloadMiniPlayer')}>
<input
type="checkbox"
<SettingsToggle
label={t('settings.preloadMiniPlayer')}
desc={t('settings.preloadMiniPlayerDesc')}
checked={auth.preloadMiniPlayer}
onChange={e => auth.setPreloadMiniPlayer(e.target.checked)}
onChange={auth.setPreloadMiniPlayer}
/>
<span className="toggle-track" />
</label>
</div>
</>
)}
</SettingsGroup>
{IS_LINUX && !isTilingWm && (
<>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.useCustomTitlebar')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.useCustomTitlebarDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.useCustomTitlebar')}>
<input type="checkbox" checked={auth.useCustomTitlebar} onChange={e => auth.setUseCustomTitlebar(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsGroup title={t('settings.groupWindow')}>
<SettingsToggle
label={t('settings.useCustomTitlebar')}
desc={t('settings.useCustomTitlebarDesc')}
checked={auth.useCustomTitlebar}
onChange={auth.setUseCustomTitlebar}
/>
{auth.useCustomTitlebar && (
<>
<div className="settings-section-divider" />
@@ -192,19 +162,15 @@ export function AppearanceTab() {
</div>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showMinimizeButton')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showMinimizeButtonDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showMinimizeButton')}>
<input type="checkbox" checked={auth.showMinimizeButton} onChange={e => auth.setShowMinimizeButton(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsToggle
label={t('settings.showMinimizeButton')}
desc={t('settings.showMinimizeButtonDesc')}
checked={auth.showMinimizeButton}
onChange={auth.setShowMinimizeButton}
/>
</>
)}
</>
</SettingsGroup>
)}
</div>
</SettingsSubSection>
@@ -214,6 +180,7 @@ export function AppearanceTab() {
icon={<ZoomIn size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
@@ -265,6 +232,7 @@ export function AppearanceTab() {
);
})()}
</div>
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -273,6 +241,7 @@ export function AppearanceTab() {
icon={<Type size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{(
[
@@ -314,6 +283,7 @@ export function AppearanceTab() {
</button>
))}
</div>
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -322,6 +292,7 @@ export function AppearanceTab() {
icon={<Sliders size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.seekbarStyleDesc')}
</div>
@@ -336,6 +307,7 @@ export function AppearanceTab() {
/>
))}
</div>
</SettingsGroup>
</div>
</SettingsSubSection>
</>
@@ -94,7 +94,6 @@ export function ArtistLayoutCustomizer() {
{t('settings.artistLayoutDesc')}
</p>
<div
className="settings-card"
style={{ padding: '4px 0' }}
ref={setContainerEl}
onMouseMove={handleMouseMove}
+13 -13
View File
@@ -4,6 +4,8 @@ import { Music2, Sliders, Waves } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import Equalizer from '../Equalizer';
import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/loudnessPreAnalysisSlider';
import { useAudioDevicesProbe } from '../../hooks/useAudioDevicesProbe';
import { AudioOutputDeviceSection } from './audio/AudioOutputDeviceSection';
@@ -51,21 +53,15 @@ export function AudioTab() {
icon={<Waves size={16} />}
>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
<input
type="checkbox"
checked={auth.enableHiRes}
onChange={e => auth.setEnableHiRes(e.target.checked)}
<SettingsGroup>
<SettingsToggle
desc={t('settings.hiResDesc')}
ariaLabel={t('settings.hiResEnabled')}
id="hires-enabled-toggle"
checked={auth.enableHiRes}
onChange={auth.setEnableHiRes}
/>
<span className="toggle-track" />
</label>
</div>
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -75,7 +71,9 @@ export function AudioTab() {
icon={<Sliders size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<Equalizer />
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -85,7 +83,9 @@ export function AudioTab() {
icon={<Gauge size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<PlaybackRateBlock t={t} />
</SettingsGroup>
</div>
</SettingsSubSection>
+6 -10
View File
@@ -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 (
<section className="settings-section">
<div className="settings-section-header">
<HardDrive size={18} />
<h2>{t('settings.backupTitle')}</h2>
</div>
<div className="settings-card" style={{ marginBottom: '1rem' }}>
<div className="settings-card">
<SettingsGroup>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.85rem' }}>
{(['full', 'library', 'config'] as BackupMode[]).map(candidate => (
<button
@@ -197,8 +193,8 @@ export function BackupSection() {
{importing ? '…' : importLabel}
</button>
</div>
</div>
</SettingsGroup>
{busyOverlay}
</section>
</div>
);
}
@@ -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 (
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
<div className="settings-card">
<SettingsGroup>
<div style={{ overflowX: 'auto' }}>
<table style={TABLE_STYLE}>
<CoverCacheColGroup />
<thead>
<tr>
<th style={TH_STYLE}>{t('settings.imageCacheScopeLabel')}</th>
<th style={{ ...TH_STYLE, paddingLeft: 0 }}>{t('settings.imageCacheScopeLabel')}</th>
<th style={STRATEGY_GAP_TH} aria-hidden="true" />
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
@@ -245,7 +247,7 @@ export default function CoverCacheStrategySection() {
</thead>
<tbody>
<tr>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>
{t('settings.imageCacheSubTitle')}
</td>
<td style={STRATEGY_GAP_TD} aria-hidden="true" />
@@ -285,7 +287,7 @@ export default function CoverCacheStrategySection() {
<CoverCacheColGroup />
<thead>
<tr>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyServerLabel')}</th>
<th style={{ ...TH_STYLE, paddingLeft: 0 }}>{t('settings.coverCacheStrategyServerLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
@@ -299,7 +301,7 @@ export default function CoverCacheStrategySection() {
const label = serverListDisplayLabel(server, servers);
return (
<tr key={server.id} style={ROW_BORDER}>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-primary)' }}>{label}</td>
<td style={{ padding: '10px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{COVER_CACHE_STRATEGIES.map(s => (
@@ -338,7 +340,7 @@ export default function CoverCacheStrategySection() {
})}
{removedServerKeys.map(key => (
<tr key={`removed-${key}`} style={ROW_BORDER}>
<td style={{ padding: '10px', fontSize: 13, color: 'var(--text-muted)' }}>
<td style={{ padding: '10px', paddingLeft: 0, fontSize: 13, color: 'var(--text-muted)' }}>
{key}
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
</td>
@@ -414,6 +416,7 @@ export default function CoverCacheStrategySection() {
</div>
</div>
)}
</SettingsGroup>
</div>
</SettingsSubSection>
);
+1 -1
View File
@@ -19,7 +19,7 @@ export function HomeCustomizer() {
};
return (
<div className="settings-card" style={{ padding: '4px 0' }}>
<div style={{ padding: '4px 0' }}>
{sections.map(sec => (
<div key={sec.id} className="sidebar-customizer-row">
<span style={{ flex: 1, fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span>
+5
View File
@@ -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,6 +33,7 @@ export function InputTab() {
}
>
<div className="settings-card">
<SettingsGroup>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{IN_APP_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
const label = getLabel(t);
@@ -95,6 +97,7 @@ export function InputTab() {
);
})}
</div>
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -116,6 +119,7 @@ export function InputTab() {
}
>
<div className="settings-card">
<SettingsGroup>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
const label = getLabel(t);
@@ -177,6 +181,7 @@ export function InputTab() {
);
})}
</div>
</SettingsGroup>
</div>
</SettingsSubSection>
</>
+1 -1
View File
@@ -71,7 +71,7 @@ export function InstalledThemes() {
];
return (
<div className="settings-card">
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))', gap: '10px' }}>
{cards.map(c => {
const isActive = active === c.id;
+46 -66
View File
@@ -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')}
</div>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}>
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsGroup title={t('settings.discordRichPresence')}>
<SettingsToggle
desc={t('settings.discordRichPresenceDesc')}
ariaLabel={t('settings.discordRichPresence')}
checked={auth.discordRichPresence}
onChange={auth.setDiscordRichPresence}
/>
</SettingsGroup>
{auth.discordRichPresence && (
<>
<div className="settings-toggle-row" style={{ padding: '4px var(--space-3) 4px var(--space-6)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{t('settings.discordCoverNone')}</div>
<label className="toggle-switch" aria-label={t('settings.discordCoverNone')}>
<input
type="checkbox"
<SettingsGroup title={t('settings.discordCoverTitle')} desc={t('settings.discordCoverDesc')}>
<SettingsToggle
label={t('settings.discordCoverNone')}
checked={auth.discordCoverSource === 'none'}
onChange={e => auth.setDiscordCoverSource(e.target.checked ? 'none' : 'server')}
onChange={c => auth.setDiscordCoverSource(c ? 'none' : 'server')}
/>
<span className="toggle-track" />
</label>
</div>
<div className="settings-toggle-row" style={{ padding: '4px var(--space-3) 4px var(--space-6)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{t('settings.discordCoverServer')}</div>
<label className="toggle-switch" aria-label={t('settings.discordCoverServer')}>
<input
type="checkbox"
checked={auth.discordCoverSource === 'server'}
onChange={e => auth.setDiscordCoverSource(e.target.checked ? 'server' : 'none')}
/>
<span className="toggle-track" />
</label>
</div>
<div className="settings-toggle-row" style={{ padding: '4px var(--space-3) 4px var(--space-6)', fontSize: 13 }}>
<div style={{ fontWeight: 500 }}>{t('settings.discordCoverApple')}</div>
<label className="toggle-switch" aria-label={t('settings.discordCoverApple')}>
<input
type="checkbox"
checked={auth.discordCoverSource === 'apple'}
onChange={e => auth.setDiscordCoverSource(e.target.checked ? 'apple' : 'none')}
/>
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div style={{ paddingTop: 8 }}>
<div style={{ fontWeight: 500, fontSize: 13, marginBottom: 8 }}>{t('settings.discordTemplates')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>{t('settings.discordTemplatesDesc')}</div>
<SettingsToggle
label={t('settings.discordCoverServer')}
checked={auth.discordCoverSource === 'server'}
onChange={c => auth.setDiscordCoverSource(c ? 'server' : 'none')}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.discordCoverApple')}
checked={auth.discordCoverSource === 'apple'}
onChange={c => auth.setDiscordCoverSource(c ? 'apple' : 'none')}
/>
</SettingsGroup>
<SettingsGroup title={t('settings.discordTemplates')} desc={t('settings.discordTemplatesDesc')}>
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateName')}</label>
<input
@@ -130,7 +114,7 @@ export function IntegrationsTab() {
placeholder="{album}"
/>
</div>
</div>
</SettingsGroup>
</>
)}
</div>
@@ -142,16 +126,14 @@ export function IntegrationsTab() {
icon={<Info size={16} />}
>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.enableBandsintown')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.enableBandsintownDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.enableBandsintown')}>
<input type="checkbox" checked={auth.enableBandsintown} onChange={e => auth.setEnableBandsintown(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsGroup>
<SettingsToggle
desc={t('settings.enableBandsintownDesc')}
ariaLabel={t('settings.enableBandsintown')}
checked={auth.enableBandsintown}
onChange={auth.setEnableBandsintown}
/>
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -161,17 +143,15 @@ export function IntegrationsTab() {
icon={<Wifi size={16} />}
>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)', marginTop: 4 }}>{t('settings.nowPlayingPluginNote')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}>
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsGroup>
<SettingsToggle
desc={t('settings.nowPlayingEnabledDesc')}
note={t('settings.nowPlayingPluginNote')}
ariaLabel={t('settings.nowPlayingEnabled')}
checked={auth.nowPlayingEnabled}
onChange={auth.setNowPlayingEnabled}
/>
</SettingsGroup>
</div>
</SettingsSubSection>
</>
+11 -36
View File
@@ -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,6 +25,7 @@ export function LibraryTab() {
icon={<Shuffle size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.randomMixBlacklistDesc')}
</p>
@@ -83,28 +86,7 @@ export function LibraryTab() {
</button>
</div>
<div className="divider" style={{ margin: '1rem 0' }} />
<div className="settings-toggle-row" style={{ marginBottom: '1rem' }}>
<div>
<div style={{ fontWeight: 500 }}>{t('settings.luckyMixMenuTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.luckyMixMenuDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.luckyMixMenuTitle')}>
<input
type="checkbox"
checked={auth.showLuckyMixMenu}
onChange={e => auth.setShowLuckyMixMenu(e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
<div className="divider" style={{ margin: '1rem 0' }} />
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
<div style={{ fontSize: 13, fontWeight: 500, margin: '1rem 0 0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{AUDIOBOOK_GENRES_DISPLAY.map(genre => (
<span key={genre} className="genre-keyword-badge" style={{
@@ -116,6 +98,7 @@ export function LibraryTab() {
</span>
))}
</div>
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -125,6 +108,7 @@ export function LibraryTab() {
icon={<Star size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div>
@@ -162,25 +146,15 @@ export function LibraryTab() {
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.ratingsMixFilterDesc', {
<SettingsToggle
label={t('settings.ratingsMixFilterTitle')}
desc={t('settings.ratingsMixFilterDesc', {
mix: t('sidebar.randomMix'),
albums: t('sidebar.randomAlbums'),
})}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
<input
type="checkbox"
checked={auth.mixMinRatingFilterEnabled}
onChange={e => auth.setMixMinRatingFilterEnabled(e.target.checked)}
onChange={auth.setMixMinRatingFilterEnabled}
/>
<span className="toggle-track" />
</label>
</div>
{auth.mixMinRatingFilterEnabled && (
<>
<div className="settings-section-divider" />
@@ -220,6 +194,7 @@ export function LibraryTab() {
</div>
</>
)}
</SettingsGroup>
</div>
</SettingsSubSection>
</>
@@ -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<LyricsSourceId, string> = {
server: 'settings.lyricsSourceServer',
@@ -98,11 +99,7 @@ export function LyricsSourcesCustomizer() {
};
return (
<section className="settings-section">
<div className="settings-section-header">
<Music2 size={18} />
<h2>{t('settings.lyricsSourcesTitle')}</h2>
</div>
<>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
{t('settings.lyricsSourcesDesc')}
</p>
@@ -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. */}
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsYouLyPlus')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsYouLyPlusDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.lyricsYouLyPlus')}>
<input
type="checkbox"
<div style={{ marginBottom: '0.75rem' }}>
<SettingsToggle
label={t('settings.lyricsYouLyPlus')}
desc={t('settings.lyricsYouLyPlusDesc')}
checked={youLyPlusEnabled}
onChange={e => setYouLyPlusEnabled(e.target.checked)}
onChange={setYouLyPlusEnabled}
/>
<span className="toggle-track" />
</label>
</div>
</div>
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 0.4rem' }}>
{youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')}
</div>
<div
className="settings-card"
style={{ padding: '4px 0', marginBottom: '0.75rem' }}
ref={setContainerEl}
onMouseMove={handleMouseMove}
@@ -162,18 +150,14 @@ export function LyricsSourcesCustomizer() {
</div>
{/* Static-only toggle — suppresses line/word tracking in both modes. */}
<div className="settings-card" style={{ marginBottom: '0.75rem' }}>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.lyricsStaticOnly')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsStaticOnlyDesc')}</div>
<div style={{ marginBottom: '0.75rem' }}>
<SettingsToggle
label={t('settings.lyricsStaticOnly')}
desc={t('settings.lyricsStaticOnlyDesc')}
checked={lyricsStaticOnly}
onChange={setLyricsStaticOnly}
/>
</div>
<label className="toggle-switch" aria-label={t('settings.lyricsStaticOnly')}>
<input type="checkbox" checked={lyricsStaticOnly} onChange={e => setLyricsStaticOnly(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div>
</section>
</>
);
}
+13 -16
View File
@@ -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={<Music2 size={16} />}
>
<SettingsGroup>
<LyricsSourcesCustomizer />
</SettingsGroup>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.sidebarLyricsStyle')}
icon={<AudioLines size={16} />}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{(['classic', 'apple'] as const).map(style => {
<SettingsGroup>
{(['classic', 'apple'] as const).map((style, i) => {
const key = style === 'classic' ? 'Classic' : 'Apple';
const other = style === 'classic' ? 'apple' : 'classic';
return (
<div key={style} className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t(`settings.sidebarLyricsStyle${key}` as any)}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t(`settings.sidebarLyricsStyle${key}Desc` as any)}</div>
</div>
<label className="toggle-switch" aria-label={t(`settings.sidebarLyricsStyle${key}` as any)}>
<input
type="checkbox"
<div key={style}>
{i > 0 && <div className="settings-section-divider" />}
<SettingsToggle
label={t(`settings.sidebarLyricsStyle${key}` as any)}
desc={t(`settings.sidebarLyricsStyle${key}Desc` as any)}
checked={sidebarLyricsStyle === style}
onChange={e => setSidebarLyricsStyle(e.target.checked ? style : other)}
onChange={c => setSidebarLyricsStyle(c ? style : other)}
/>
<span className="toggle-track" />
</label>
</div>
</div>
);
})}
</div>
</SettingsGroup>
</SettingsSubSection>
</>
);
+28 -36
View File
@@ -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() {
</button>
}
>
<SettingsGroup>
<SidebarCustomizer />
</SettingsGroup>
</SettingsSubSection>
<SettingsSubSection
@@ -56,7 +60,9 @@ export function PersonalisationTab() {
</button>
}
>
<SettingsGroup>
<HomeCustomizer />
</SettingsGroup>
</SettingsSubSection>
<SettingsSubSection
@@ -76,7 +82,9 @@ export function PersonalisationTab() {
</button>
}
>
<SettingsGroup>
<ArtistLayoutCustomizer />
</SettingsGroup>
</SettingsSubSection>
<SettingsSubSection
@@ -84,53 +92,31 @@ export function PersonalisationTab() {
icon={<ListOrdered size={16} />}
>
<div className="settings-card">
<SettingsGroup>
{/* 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). */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('queue.title')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModeQueueSub')}</div>
</div>
<label className="toggle-switch" aria-label={t('queue.title')}>
<input
type="checkbox"
<SettingsToggle
label={t('queue.title')}
desc={t('settings.queueModeQueueSub')}
checked={queueDisplayMode === 'queue'}
onChange={e => { if (e.target.checked) setQueueDisplayMode('queue'); }}
onChange={c => { if (c) setQueueDisplayMode('queue'); }}
/>
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('queue.modePlaylist')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModePlaylistSub')}</div>
</div>
<label className="toggle-switch" aria-label={t('queue.modePlaylist')}>
<input
type="checkbox"
<SettingsToggle
label={t('queue.modePlaylist')}
desc={t('settings.queueModePlaylistSub')}
checked={queueDisplayMode === 'playlist'}
onChange={e => { if (e.target.checked) setQueueDisplayMode('playlist'); }}
onChange={c => { if (c) setQueueDisplayMode('playlist'); }}
/>
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('queue.modeTimeline')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.queueModeTimelineSub')}</div>
</div>
<label className="toggle-switch" aria-label={t('queue.modeTimeline')}>
<input
type="checkbox"
<SettingsToggle
label={t('queue.modeTimeline')}
desc={t('settings.queueModeTimelineSub')}
checked={queueDisplayMode === 'timeline'}
onChange={e => { if (e.target.checked) setQueueDisplayMode('timeline'); }}
onChange={c => { if (c) setQueueDisplayMode('timeline'); }}
/>
<span className="toggle-track" />
</label>
</div>
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -151,7 +137,9 @@ export function PersonalisationTab() {
</button>
}
>
<SettingsGroup>
<QueueToolbarCustomizer />
</SettingsGroup>
</SettingsSubSection>
<SettingsSubSection
@@ -171,7 +159,9 @@ export function PersonalisationTab() {
</button>
}
>
<SettingsGroup>
<PlaylistLayoutCustomizer />
</SettingsGroup>
</SettingsSubSection>
<SettingsSubSection
@@ -191,7 +181,9 @@ export function PersonalisationTab() {
</button>
}
>
<SettingsGroup>
<PlayerBarLayoutCustomizer />
</SettingsGroup>
</SettingsSubSection>
</>
);
@@ -35,7 +35,7 @@ export function PlayerBarLayoutCustomizer() {
const toggleItem = usePlayerBarLayoutStore(s => s.toggleItem);
return (
<div className="settings-card" style={{ padding: '4px 0' }}>
<div style={{ padding: '4px 0' }}>
{items.map((it) => {
const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]);
return (
@@ -24,7 +24,7 @@ export function PlaylistLayoutCustomizer() {
const toggleItem = usePlaylistLayoutStore(s => s.toggleItem);
return (
<div className="settings-card" style={{ padding: '4px 0' }}>
<div style={{ padding: '4px 0' }}>
{items.map((it) => {
const Icon = PLAYLIST_LAYOUT_ICONS[it.id];
const label = t(PLAYLIST_LAYOUT_LABEL_KEYS[it.id]);
@@ -104,7 +104,7 @@ export function QueueToolbarCustomizer() {
};
return (
<div ref={containerRef} onMouseMove={handleMouseMove} className="settings-card" style={{ padding: '4px 0' }}>
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ padding: '4px 0' }}>
{buttons.map((btn, idx) => {
const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id];
const label = t(QUEUE_TOOLBAR_LABEL_KEYS[btn.id]);
@@ -63,19 +63,19 @@ export default function ServerLibraryIndexControls({
borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)',
}}
>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45, marginBottom: '0.5rem' }}>
{connection === 'offline' && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, marginRight: '0.5rem' }}>
<WifiOff size={12} />
{t('settings.libraryIndexServerDeferred')}
</span>
)}
{busy && (
<span style={{ color: 'var(--accent)', marginRight: '0.5rem' }}>
{t('settings.libraryIndexServerSyncing')}
</span>
)}
<div style={{ fontSize: 12, lineHeight: 1.45, marginBottom: '0.5rem' }}>
{connection === 'offline' ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, color: 'var(--text-muted)' }}>
<WifiOff size={12} style={{ flexShrink: 0 }} />
{phaseLabel}
</span>
) : busy ? (
<span style={{ color: 'var(--accent)' }}>
{t('settings.libraryIndexServerSyncing')} {phaseLabel}
</span>
) : (
<span style={{ color: 'var(--text-muted)' }}>{phaseLabel}</span>
)}
</div>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<button
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
interface Props {
/** Accent uppercase header. Omit for a plain boxed panel (no header)
* used when the surrounding SettingsSubSection already names the group. */
title?: string;
/** Optional accent-coloured icon shown before the title (e.g. on the flat
* Themes sections). Ignored when `title` is omitted. */
icon?: React.ReactNode;
/** Optional one-line description shown under the title. */
desc?: string;
children: React.ReactNode;
}
/**
* Boxed settings sub-section a bordered panel (optionally with an accent
* uppercase header) that sets a group of related controls apart inside a
* settings card. Wraps the `.settings-group` styles so the look stays
* consistent everywhere it is used (Audio, Appearance, Library, ).
*/
export function SettingsGroup({ title, icon, desc, children }: Props) {
return (
<div className="settings-group">
{title && (
<div className="settings-group-title">
{icon && <span className="settings-group-title-icon">{icon}</span>}
{title}
</div>
)}
<div className="settings-group-body">
{desc && <div className="settings-group-desc">{desc}</div>}
{children}
</div>
</div>
);
}
@@ -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 (
<div className="settings-toggle-row" data-settings-search={searchText} style={disabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
<div>
{label && <div style={{ fontWeight: 500 }}>{label}</div>}
{desc && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{desc}</div>}
{note && <div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)', marginTop: 4 }}>{note}</div>}
</div>
<label className="toggle-switch" aria-label={ariaLabel ?? label}>
<input type="checkbox" id={id} checked={checked} disabled={disabled} onChange={e => onChange(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
);
}
+21 -27
View File
@@ -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 (
<>
<div className="settings-card" style={{ marginBottom: '1rem' }}>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.randomNavSplitTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.randomNavSplitDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.randomNavSplitTitle')}>
<input
type="checkbox"
<div style={{ marginBottom: '1rem' }}>
<SettingsToggle
label={t('settings.randomNavSplitTitle')}
desc={t('settings.randomNavSplitDesc')}
checked={randomNavMode === 'separate'}
onChange={e => setRandomNavMode(e.target.checked ? 'separate' : 'hub')}
onChange={c => setRandomNavMode(c ? 'separate' : 'hub')}
/>
<span className="toggle-track" />
</label>
</div>
<div className="settings-toggle-row" data-settings-search={t('settings.nowPlayingTopTitle')}>
<div>
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingTopTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingTopDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.nowPlayingTopTitle')}>
<input
type="checkbox"
<SettingsToggle
label={t('settings.nowPlayingTopTitle')}
desc={t('settings.nowPlayingTopDesc')}
searchText={t('settings.nowPlayingTopTitle')}
checked={nowPlayingAtTop}
onChange={e => setNowPlayingAtTop(e.target.checked)}
onChange={setNowPlayingAtTop}
/>
<SettingsToggle
label={t('settings.luckyMixMenuTitle')}
desc={t('settings.luckyMixMenuDesc')}
checked={showLuckyMixMenu}
onChange={setShowLuckyMixMenu}
/>
<span className="toggle-track" />
</label>
</div>
</div>
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* Library block */}
<div className="settings-card" style={{ padding: '4px 0' }}>
<div style={{ padding: '4px 0' }}>
<div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
{libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
</div>
{/* System block */}
<div className="settings-card" style={{ padding: '4px 0' }}>
<div style={{ padding: '4px 0' }}>
<div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
<div className="sidebar-customizer-fixed-hint">
+14 -20
View File
@@ -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,9 +76,7 @@ export function StorageTab() {
icon={<FolderOpen size={16} />}
>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
{t('settings.mediaDirDesc')}
</div>
<SettingsGroup desc={t('settings.mediaDirDesc')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="input"
@@ -104,6 +104,7 @@ export function StorageTab() {
{t('settings.mediaDirHint')}
</div>
)}
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -114,17 +115,14 @@ export function StorageTab() {
icon={<Download size={16} />}
>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hotCacheDisclaimer')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}>
<input
type="checkbox"
<SettingsGroup>
<SettingsToggle
label={t('settings.hotCacheTitle')}
desc={t('settings.hotCacheDisclaimer')}
ariaLabel={t('settings.hotCacheEnabled')}
id="hot-cache-enabled-toggle"
checked={auth.hotCacheEnabled}
onChange={async e => {
const enabled = e.target.checked;
onChange={async enabled => {
if (!enabled) {
await clearHotCacheDisk(mediaDir);
setHotCacheBytes(0);
@@ -134,11 +132,7 @@ export function StorageTab() {
refreshHotCacheSize();
}
}}
id="hot-cache-enabled-toggle"
/>
<span className="toggle-track" />
</label>
</div>
{auth.hotCacheEnabled && (
<div style={{ marginTop: '1.25rem' }}>
@@ -186,6 +180,7 @@ export function StorageTab() {
</button>
</div>
)}
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -194,9 +189,7 @@ export function StorageTab() {
icon={<FolderOpen size={16} />}
>
<div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}>
{t('settings.downloadsFolderDesc')}
</div>
<SettingsGroup desc={t('settings.downloadsFolderDesc')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
className="input"
@@ -220,6 +213,7 @@ export function StorageTab() {
<FolderOpen size={16} /> {t('settings.pickFolder')}
</button>
</div>
</SettingsGroup>
</div>
</SettingsSubSection>
</>
+38 -60
View File
@@ -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,6 +57,7 @@ export function SystemTab() {
icon={<Globe size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<div className="form-group" style={{ maxWidth: '300px' }}>
<CustomSelect
value={i18n.language}
@@ -72,6 +75,7 @@ export function SystemTab() {
]}
/>
</div>
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -81,59 +85,37 @@ export function SystemTab() {
icon={<AppWindow size={16} />}
>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showTrayIcon')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showTrayIconDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showTrayIcon')}>
<input type="checkbox" checked={auth.showTrayIcon} onChange={e => auth.setShowTrayIcon(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsGroup title={t('settings.groupTray')}>
<SettingsToggle
label={t('settings.showTrayIcon')}
desc={t('settings.showTrayIconDesc')}
checked={auth.showTrayIcon}
onChange={auth.setShowTrayIcon}
/>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}>
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsToggle
label={t('settings.minimizeToTray')}
desc={t('settings.minimizeToTrayDesc')}
checked={auth.minimizeToTray}
onChange={auth.setMinimizeToTray}
/>
</SettingsGroup>
{IS_LINUX && (
<>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.linuxWebkitSmoothScroll')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.linuxWebkitSmoothScrollDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.linuxWebkitSmoothScroll')}>
<input
type="checkbox"
<SettingsGroup title={t('settings.groupLinuxRendering')}>
<SettingsToggle
label={t('settings.linuxWebkitSmoothScroll')}
desc={t('settings.linuxWebkitSmoothScrollDesc')}
checked={auth.linuxWebkitKineticScroll}
onChange={e => auth.setLinuxWebkitKineticScroll(e.target.checked)}
onChange={auth.setLinuxWebkitKineticScroll}
/>
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.linuxWebkitInputForceRepaint')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.linuxWebkitInputForceRepaintDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.linuxWebkitInputForceRepaint')}>
<input
type="checkbox"
<SettingsToggle
label={t('settings.linuxWebkitInputForceRepaint')}
desc={t('settings.linuxWebkitInputForceRepaintDesc')}
checked={auth.linuxWebkitInputForceRepaint}
onChange={e => auth.setLinuxWebkitInputForceRepaint(e.target.checked)}
onChange={auth.setLinuxWebkitInputForceRepaint}
/>
<span className="toggle-track" />
</label>
</div>
{waylandTextRenderAvailable && (
<>
<div className="settings-section-divider" />
@@ -155,9 +137,10 @@ export function SystemTab() {
</div>
</>
)}
</>
</SettingsGroup>
)}
<div className="settings-section-divider" />
<SettingsGroup title={t('settings.groupClock')}>
<div className="settings-toggle-row">
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>{t('settings.clockFormat')}</div>
@@ -175,6 +158,7 @@ export function SystemTab() {
/>
</div>
</div>
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -190,6 +174,7 @@ export function SystemTab() {
icon={<Sliders size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.loggingModeDesc')}
</div>
@@ -210,6 +195,7 @@ export function SystemTab() {
</button>
</div>
)}
</SettingsGroup>
</div>
</SettingsSubSection>
@@ -271,20 +257,12 @@ export function SystemTab() {
</div>
<div className="settings-section-divider" style={{ marginTop: '1.25rem' }} />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showChangelogOnUpdate')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showChangelogOnUpdateDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.showChangelogOnUpdate')}>
<input
type="checkbox"
<SettingsToggle
label={t('settings.showChangelogOnUpdate')}
desc={t('settings.showChangelogOnUpdateDesc')}
checked={auth.showChangelogOnUpdate}
onChange={e => auth.setShowChangelogOnUpdate(e.target.checked)}
onChange={auth.setShowChangelogOnUpdate}
/>
<span className="toggle-track" />
</label>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.25rem', flexWrap: 'wrap' }}>
<button
@@ -62,7 +62,7 @@ export function ThemeImportSection() {
};
return (
<div className="settings-card">
<div>
<button
type="button"
onClick={handleImport}
+15 -5
View File
@@ -9,6 +9,7 @@ import { FIXED_THEMES } from './fixedThemes';
import { InstalledThemes } from './InstalledThemes';
import { ThemeImportSection } from './ThemeImportSection';
import { ThemeStoreSection } from './ThemeStoreSection';
import { SettingsGroup } from './SettingsGroup';
/**
* A flat, always-visible section. The Themes tab has a single purpose, so its
@@ -16,7 +17,16 @@ import { ThemeStoreSection } from './ThemeStoreSection';
* the collapsible <details> SettingsSubSection used elsewhere. `data-settings-
* search` keeps each section reachable from the global settings search.
*/
function ThemesSection({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
function ThemesSection({ icon, title, children, boxed }: { icon: ReactNode; title: string; children: ReactNode; boxed?: boolean }) {
if (boxed) {
return (
<section className="themes-section" data-settings-search={title} style={{ marginBottom: '1.75rem' }}>
<div className="settings-card">
<SettingsGroup title={title} icon={icon}>{children}</SettingsGroup>
</div>
</section>
);
}
return (
<section className="themes-section" data-settings-search={title} style={{ marginBottom: '1.75rem' }}>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 15, fontWeight: 600, margin: '0 0 0.75rem' }}>
@@ -39,7 +49,7 @@ export function ThemesTab() {
return (
<>
<ThemesSection icon={<Palette size={16} />} title={t('settings.themesYourThemesTitle')}>
<ThemesSection icon={<Palette size={16} />} title={t('settings.themesYourThemesTitle')} boxed>
{theme.enableThemeScheduler && (
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
{t('settings.themeSchedulerActiveHint')}
@@ -48,8 +58,8 @@ export function ThemesTab() {
<InstalledThemes />
</ThemesSection>
<ThemesSection icon={<Clock size={16} />} title={t('settings.themeSchedulerTitle')}>
<div className="settings-card">
<ThemesSection icon={<Clock size={16} />} title={t('settings.themeSchedulerTitle')} boxed>
<div>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.themeSchedulerEnable')}</div>
@@ -114,7 +124,7 @@ export function ThemesTab() {
</div>
</ThemesSection>
<ThemesSection icon={<Upload size={16} />} title={t('settings.themeImportTitle')}>
<ThemesSection icon={<Upload size={16} />} title={t('settings.themeImportTitle')} boxed>
<ThemeImportSection />
</ThemesSection>
@@ -4,6 +4,7 @@ import { AudioLines, RotateCcw } from 'lucide-react';
import type { TFunction } from 'i18next';
import CustomSelect from '../../CustomSelect';
import SettingsSubSection from '../../SettingsSubSection';
import { SettingsGroup } from '../SettingsGroup';
import { useAuthStore } from '../../../store/authStore';
import { IS_MACOS } from '../../../utils/platform';
import { buildAudioDeviceSelectOptions } from '../../../utils/audio/audioDeviceLabels';
@@ -43,6 +44,7 @@ export function AudioOutputDeviceSection({
icon={<AudioLines size={16} />}
>
<div className="settings-card">
<SettingsGroup>
{IS_MACOS ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}>
{t('settings.audioOutputDeviceMacNotice')}
@@ -86,6 +88,7 @@ export function AudioOutputDeviceSection({
</div>
</>
)}
</SettingsGroup>
</div>
</SettingsSubSection>
);
@@ -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 (
<div className="settings-group">
<div className="settings-group-title">{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
<div className="settings-group-desc">{t('settings.normalizationDesc')}</div>
<SettingsGroup
title={t('settings.normalization', { defaultValue: 'Normalization' })}
desc={t('settings.normalizationDesc')}
>
<div className="settings-segmented" style={{ marginBottom: auth.normalizationEngine === 'off' ? 0 : '0.85rem' }}>
<button
type="button"
@@ -176,6 +178,6 @@ export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
<div className="settings-norm-note">{t('settings.loudnessFirstPlayNote')}</div>
</div>
)}
</div>
</SettingsGroup>
);
}
@@ -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 (
<>
<div className="settings-group">
<div className="settings-group-title">{t('settings.transitionsTitle')}</div>
<div className="settings-group-desc">{t('settings.transitionsDesc')}</div>
<SettingsGroup title={t('settings.transitionsTitle')} desc={t('settings.transitionsDesc')}>
<div className="settings-segmented">
{transitions.map(item => (
<button
@@ -74,27 +73,16 @@ export function PlaybackBehaviorBlock({ t }: Props) {
{t('settings.autoDjDesc')}
</div>
)}
</div>
</SettingsGroup>
<div className="settings-group">
<div className="settings-group-title">{t('settings.queueBehaviourTitle')}</div>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.preservePlayNextOrder')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.preservePlayNextOrderDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.preservePlayNextOrder')}>
<input type="checkbox" checked={auth.preservePlayNextOrder}
onChange={e => auth.setPreservePlayNextOrder(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div>
<SettingsGroup title={t('settings.queueBehaviourTitle')}>
<SettingsToggle
label={t('settings.preservePlayNextOrder')}
desc={t('settings.preservePlayNextOrderDesc')}
checked={auth.preservePlayNextOrder}
onChange={auth.setPreservePlayNextOrder}
/>
</SettingsGroup>
</>
);
}
@@ -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 && (
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.playbackRateEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.playbackRateEnabledDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.playbackRateEnabled')}>
<input
type="checkbox"
<SettingsToggle
label={t('settings.playbackRateEnabled')}
desc={t('settings.playbackRateEnabledDesc')}
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
onChange={setEnabled}
/>
<span className="toggle-track" />
</label>
</div>
)}
{(!showEnable || enabled) && (
@@ -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,21 +28,13 @@ export function TrackPreviewsSection({ t }: Props) {
icon={<Play size={16} />}
>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.trackPreviewsToggle')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.trackPreviewsDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.trackPreviewsToggle')}>
<input type="checkbox" checked={auth.trackPreviewsEnabled}
onChange={e => auth.setTrackPreviewsEnabled(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<SettingsGroup>
<SettingsToggle
label={t('settings.trackPreviewsToggle')}
desc={t('settings.trackPreviewsDesc')}
checked={auth.trackPreviewsEnabled}
onChange={auth.setTrackPreviewsEnabled}
/>
{auth.trackPreviewsEnabled && (
<>
@@ -123,6 +117,7 @@ export function TrackPreviewsSection({ t }: Props) {
</div>
</>
)}
</SettingsGroup>
</div>
</SettingsSubSection>
);
@@ -71,13 +71,13 @@ export function ConnectProviderForm({
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<div style={{ fontWeight: 500, fontSize: 13 }}>{t('musicNetwork.addService')}</div>
<div className="settings-group-title">{t('musicNetwork.addService')}</div>
{available.map(preset => {
const id = preset.manifest.presetId;
const isExpanded = expanded === id;
const isBusy = busy === id;
return (
<div key={id} className="settings-card" style={{ padding: '0.75rem 1rem' }}>
<div key={id} className="settings-group" style={{ padding: '0.75rem 1rem', marginTop: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ flexShrink: 0 }} aria-hidden="true">{renderPresetIcon(preset.manifest.icon, 18)}</div>
<div style={{ flex: 1, minWidth: 0 }}>
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import CustomSelect from '../../CustomSelect';
import { SettingsGroup } from '../SettingsGroup';
import type { Account } from '../../../music-network';
/**
@@ -26,6 +27,7 @@ export function EnrichmentPrimarySelect({
];
return (
<SettingsGroup title={t('musicNetwork.primaryLabel')}>
<div
style={{
display: 'flex',
@@ -33,15 +35,9 @@ export function EnrichmentPrimarySelect({
justifyContent: 'space-between',
gap: '1rem',
flexWrap: 'wrap',
padding: '0.75rem 1rem',
borderRadius: '10px',
border: '1px solid var(--border)',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>{t('musicNetwork.primaryLabel')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.primaryDesc')}</div>
</div>
<div style={{ minWidth: 0, fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.primaryDesc')}</div>
<CustomSelect
value={primaryId ?? ''}
options={options}
@@ -49,5 +45,6 @@ export function EnrichmentPrimarySelect({
style={{ minWidth: 180 }}
/>
</div>
</SettingsGroup>
);
}
@@ -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')}
</p>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
padding: '0.75rem 1rem',
borderRadius: '10px',
border: '1px solid var(--border)',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>{t('musicNetwork.masterToggle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.masterToggleDesc')}</div>
</div>
<SettingsGroup title={t('musicNetwork.masterToggle')}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
<div style={{ minWidth: 0, fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.masterToggleDesc')}</div>
<label className="toggle-switch" style={{ flexShrink: 0 }} aria-label={t('musicNetwork.masterToggle')}>
<input type="checkbox" checked={scrobblingMasterEnabled} onChange={e => setMaster(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</SettingsGroup>
<div style={{ marginTop: '0.75rem' }}>
<EnrichmentPrimarySelect
accounts={accounts}
primaryId={enrichmentPrimaryId}
onChange={setPrimary}
/>
</div>
{accounts.length > 0 && (
<>
<div className="settings-section-divider" />
<SettingsGroup>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{accounts.map(account => (
<ScrobbleDestinationCard
@@ -120,14 +107,14 @@ export function MusicNetworkSection() {
/>
))}
</div>
<MalojaProxyWarning accounts={accounts} />
</>
</SettingsGroup>
)}
<div className="settings-section-divider" />
<div style={{ marginTop: 'var(--space-3)' }}>
<ConnectProviderForm connectedPresetIds={connectedPresetIds} onConnect={connect} />
</div>
</div>
</SettingsSubSection>
);
}
+8 -4
View File
@@ -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',
+8 -4
View File
@@ -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',
+8 -4
View File
@@ -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',
+8 -4
View File
@@ -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 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 lindex ou ajoutez un serveur.',
libraryIndexServerOffline: 'Serveur hors ligne — synchronisation reportée',
libraryIndexServerDeferred: 'Reportée',
libraryIndexServerSyncing: 'Synchronisation…',
libraryIndexFullResync: 'Resynchronisation complète',
libraryIndexDeltaSync: 'Synchronisation delta',
+8 -4
View File
@@ -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',
+8 -4
View File
@@ -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',
+8 -4
View File
@@ -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 coper',
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',
+8 -4
View File
@@ -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: 'Быстрая дельта',
+8 -4
View File
@@ -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: '增量同步',
+26
View File
@@ -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;
}
+4
View File
@@ -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;