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. * 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 ## Fixed
### Seeking in streamed Opus/Ogg tracks ### 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. - 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. - 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 ## Fixed
### Playback and audio ### Playback and audio
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { save as saveDialog } from '@tauri-apps/plugin-dialog'; import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { writeFile } from '@tauri-apps/plugin-fs'; import { writeFile } from '@tauri-apps/plugin-fs';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore'; import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { import {
@@ -253,11 +254,12 @@ export default function AnalyticsStrategySection() {
icon={<BarChart3 size={16} />} icon={<BarChart3 size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}> <table style={{ width: '100%', borderCollapse: 'collapse', minWidth: 560 }}>
<thead> <thead>
<tr> <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')} {t('settings.analyticsStrategyServerLabel')}
</th> </th>
<th style={{ textAlign: 'left', padding: '8px 10px', fontSize: 12, color: 'var(--text-muted)' }}> <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); const label = serverListDisplayLabel(server, servers);
return ( return (
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}> <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} {label}
</td> </td>
<td style={{ padding: '10px' }}> <td style={{ padding: '10px' }}>
@@ -365,7 +367,7 @@ export default function AnalyticsStrategySection() {
key={serverId} key={serverId}
style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }} 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>{serverId}</div>
<div style={{ fontSize: 11, color: 'var(--warning, #f59e0b)', marginTop: 2 }}> <div style={{ fontSize: 11, color: 'var(--warning, #f59e0b)', marginTop: 2 }}>
{t('settings.analyticsStrategyServerRemoved')} {t('settings.analyticsStrategyServerRemoved')}
@@ -432,6 +434,7 @@ export default function AnalyticsStrategySection() {
{t('settings.analyticsStrategyAdvancedWarning')} {t('settings.analyticsStrategyAdvancedWarning')}
</span> </span>
</div> </div>
</SettingsGroup>
</div> </div>
{clearTarget && {clearTarget &&
+67 -95
View File
@@ -12,6 +12,8 @@ import { useFontStore, FontId } from '../../store/fontStore';
import { useThemeStore } from '../../store/themeStore'; import { useThemeStore } from '../../store/themeStore';
import { IS_LINUX, IS_WINDOWS } from '../../utils/platform'; import { IS_LINUX, IS_WINDOWS } from '../../utils/platform';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import { SeekbarPreview } from '../WaveformSeekPreview'; import { SeekbarPreview } from '../WaveformSeekPreview';
import WindowButtonPreview from '../WindowButtonPreview'; import WindowButtonPreview from '../WindowButtonPreview';
@@ -34,6 +36,7 @@ export function AppearanceTab() {
icon={<LayoutGrid size={16} />} icon={<LayoutGrid size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}> <div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
{t('settings.libraryGridMaxColumnsPerfHint')} {t('settings.libraryGridMaxColumnsPerfHint')}
</div> </div>
@@ -66,6 +69,7 @@ export function AppearanceTab() {
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.75rem', lineHeight: 1.45 }}> <p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.75rem', lineHeight: 1.45 }}>
{t('settings.libraryGridMaxColumnsDesc')} {t('settings.libraryGridMaxColumnsDesc')}
</p> </p>
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -74,103 +78,69 @@ export function AppearanceTab() {
icon={<Palette size={16} />} icon={<Palette size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<div className="settings-toggle-row"> <SettingsGroup title={t('settings.groupDisplay')}>
<div> <SettingsToggle
<div style={{ fontWeight: 500 }}>{t('settings.coverArtBackground')}</div> label={t('settings.coverArtBackground')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.coverArtBackgroundSub')}</div> desc={t('settings.coverArtBackgroundSub')}
</div> checked={theme.enableCoverArtBackground}
<label className="toggle-switch"> onChange={theme.setEnableCoverArtBackground}
<input type="checkbox" checked={theme.enableCoverArtBackground} onChange={e => theme.setEnableCoverArtBackground(e.target.checked)} /> />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.playlistCoverPhoto')}
<div style={{ fontWeight: 500 }}>{t('settings.playlistCoverPhoto')}</div> desc={t('settings.playlistCoverPhotoSub')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.playlistCoverPhotoSub')}</div> checked={theme.enablePlaylistCoverPhoto}
</div> onChange={theme.setEnablePlaylistCoverPhoto}
<label className="toggle-switch"> />
<input type="checkbox" checked={theme.enablePlaylistCoverPhoto} onChange={e => theme.setEnablePlaylistCoverPhoto(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.showBitrate')}
<div style={{ fontWeight: 500 }}>{t('settings.showBitrate')}</div> desc={t('settings.showBitrateSub')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showBitrateSub')}</div> checked={theme.showBitrate}
</div> onChange={theme.setShowBitrate}
<label className="toggle-switch"> />
<input type="checkbox" checked={theme.showBitrate} onChange={e => theme.setShowBitrate(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.floatingPlayerBar')}
<div style={{ fontWeight: 500 }}>{t('settings.floatingPlayerBar')}</div> desc={t('settings.floatingPlayerBarSub')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.floatingPlayerBarSub')}</div> checked={theme.floatingPlayerBar}
</div> onChange={theme.setFloatingPlayerBar}
<label className="toggle-switch"> />
<input type="checkbox" checked={theme.floatingPlayerBar} onChange={e => theme.setFloatingPlayerBar(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.showArtistImages')}
<div style={{ fontWeight: 500 }}>{t('settings.showArtistImages')}</div> desc={t('settings.showArtistImagesDesc')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showArtistImagesDesc')}</div> checked={auth.showArtistImages}
</div> onChange={auth.setShowArtistImages}
<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>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.showOrbitTrigger')}
<div style={{ fontWeight: 500 }}>{t('settings.showOrbitTrigger')}</div> desc={t('settings.showOrbitTriggerDesc')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showOrbitTriggerDesc')}</div> checked={auth.showOrbitTrigger}
</div> onChange={auth.setShowOrbitTrigger}
<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>
{!IS_WINDOWS && ( {!IS_WINDOWS && (
<> <>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.preloadMiniPlayer')}
<div style={{ fontWeight: 500 }}>{t('settings.preloadMiniPlayer')}</div> desc={t('settings.preloadMiniPlayerDesc')}
<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"
checked={auth.preloadMiniPlayer} checked={auth.preloadMiniPlayer}
onChange={e => auth.setPreloadMiniPlayer(e.target.checked)} onChange={auth.setPreloadMiniPlayer}
/> />
<span className="toggle-track" />
</label>
</div>
</> </>
)} )}
</SettingsGroup>
{IS_LINUX && !isTilingWm && ( {IS_LINUX && !isTilingWm && (
<> <SettingsGroup title={t('settings.groupWindow')}>
<div className="settings-section-divider" /> <SettingsToggle
<div className="settings-toggle-row"> label={t('settings.useCustomTitlebar')}
<div> desc={t('settings.useCustomTitlebarDesc')}
<div style={{ fontWeight: 500 }}>{t('settings.useCustomTitlebar')}</div> checked={auth.useCustomTitlebar}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.useCustomTitlebarDesc')}</div> onChange={auth.setUseCustomTitlebar}
</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>
{auth.useCustomTitlebar && ( {auth.useCustomTitlebar && (
<> <>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
@@ -192,19 +162,15 @@ export function AppearanceTab() {
</div> </div>
</div> </div>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.showMinimizeButton')}
<div style={{ fontWeight: 500 }}>{t('settings.showMinimizeButton')}</div> desc={t('settings.showMinimizeButtonDesc')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showMinimizeButtonDesc')}</div> checked={auth.showMinimizeButton}
</div> onChange={auth.setShowMinimizeButton}
<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>
</> </>
)} )}
</> </SettingsGroup>
)} )}
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -214,6 +180,7 @@ export function AppearanceTab() {
icon={<ZoomIn size={16} />} icon={<ZoomIn size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span> <span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
@@ -265,6 +232,7 @@ export function AppearanceTab() {
); );
})()} })()}
</div> </div>
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -273,6 +241,7 @@ export function AppearanceTab() {
icon={<Type size={16} />} icon={<Type size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{( {(
[ [
@@ -314,6 +283,7 @@ export function AppearanceTab() {
</button> </button>
))} ))}
</div> </div>
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -322,6 +292,7 @@ export function AppearanceTab() {
icon={<Sliders size={16} />} icon={<Sliders size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.seekbarStyleDesc')} {t('settings.seekbarStyleDesc')}
</div> </div>
@@ -336,6 +307,7 @@ export function AppearanceTab() {
/> />
))} ))}
</div> </div>
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
</> </>
@@ -94,7 +94,6 @@ export function ArtistLayoutCustomizer() {
{t('settings.artistLayoutDesc')} {t('settings.artistLayoutDesc')}
</p> </p>
<div <div
className="settings-card"
style={{ padding: '4px 0' }} style={{ padding: '4px 0' }}
ref={setContainerEl} ref={setContainerEl}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
+13 -13
View File
@@ -4,6 +4,8 @@ import { Music2, Sliders, Waves } from 'lucide-react';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import Equalizer from '../Equalizer'; import Equalizer from '../Equalizer';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/loudnessPreAnalysisSlider'; import { effectiveLoudnessPreAnalysisAttenuationDb } from '../../utils/audio/loudnessPreAnalysisSlider';
import { useAudioDevicesProbe } from '../../hooks/useAudioDevicesProbe'; import { useAudioDevicesProbe } from '../../hooks/useAudioDevicesProbe';
import { AudioOutputDeviceSection } from './audio/AudioOutputDeviceSection'; import { AudioOutputDeviceSection } from './audio/AudioOutputDeviceSection';
@@ -51,21 +53,15 @@ export function AudioTab() {
icon={<Waves size={16} />} icon={<Waves size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<div className="settings-toggle-row"> <SettingsGroup>
<div> <SettingsToggle
<div style={{ fontWeight: 500 }}>{t('settings.hiResEnabled')}</div> desc={t('settings.hiResDesc')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hiResDesc')}</div> ariaLabel={t('settings.hiResEnabled')}
</div>
<label className="toggle-switch" aria-label={t('settings.hiResEnabled')}>
<input
type="checkbox"
checked={auth.enableHiRes}
onChange={e => auth.setEnableHiRes(e.target.checked)}
id="hires-enabled-toggle" id="hires-enabled-toggle"
checked={auth.enableHiRes}
onChange={auth.setEnableHiRes}
/> />
<span className="toggle-track" /> </SettingsGroup>
</label>
</div>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -75,7 +71,9 @@ export function AudioTab() {
icon={<Sliders size={16} />} icon={<Sliders size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<Equalizer /> <Equalizer />
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -85,7 +83,9 @@ export function AudioTab() {
icon={<Gauge size={16} />} icon={<Gauge size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<PlaybackRateBlock t={t} /> <PlaybackRateBlock t={t} />
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
+6 -10
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { createPortal } from 'react-dom';
import { SettingsGroup } from './SettingsGroup';
import { import {
exportBackupToPath, exportBackupToPath,
importAnyBackupFromPath, importAnyBackupFromPath,
@@ -148,13 +149,8 @@ export function BackupSection() {
: null; : null;
return ( return (
<section className="settings-section"> <div className="settings-card">
<div className="settings-section-header"> <SettingsGroup>
<HardDrive size={18} />
<h2>{t('settings.backupTitle')}</h2>
</div>
<div className="settings-card" style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.85rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.85rem' }}>
{(['full', 'library', 'config'] as BackupMode[]).map(candidate => ( {(['full', 'library', 'config'] as BackupMode[]).map(candidate => (
<button <button
@@ -197,8 +193,8 @@ export function BackupSection() {
{importing ? '…' : importLabel} {importing ? '…' : importLabel}
</button> </button>
</div> </div>
</div> </SettingsGroup>
{busyOverlay} {busyOverlay}
</section> </div>
); );
} }
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'r
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { useCoverStrategyStore } from '../../store/coverStrategyStore'; import { useCoverStrategyStore } from '../../store/coverStrategyStore';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { import {
@@ -232,12 +233,13 @@ export default function CoverCacheStrategySection() {
return ( return (
<SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}> <SettingsSubSection title={t('settings.coverCacheStrategyTitle')} icon={<Image size={16} />}>
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table style={TABLE_STYLE}> <table style={TABLE_STYLE}>
<CoverCacheColGroup /> <CoverCacheColGroup />
<thead> <thead>
<tr> <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={STRATEGY_GAP_TH} aria-hidden="true" />
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th> <th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th> <th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
@@ -245,7 +247,7 @@ export default function CoverCacheStrategySection() {
</thead> </thead>
<tbody> <tbody>
<tr> <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')} {t('settings.imageCacheSubTitle')}
</td> </td>
<td style={STRATEGY_GAP_TD} aria-hidden="true" /> <td style={STRATEGY_GAP_TD} aria-hidden="true" />
@@ -285,7 +287,7 @@ export default function CoverCacheStrategySection() {
<CoverCacheColGroup /> <CoverCacheColGroup />
<thead> <thead>
<tr> <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.coverCacheStrategyLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th> <th style={TH_STYLE}>{t('settings.coverCacheStrategyProgressLabel')}</th>
<th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th> <th style={TH_STYLE}>{t('settings.coverCacheStrategyActionsLabel')}</th>
@@ -299,7 +301,7 @@ export default function CoverCacheStrategySection() {
const label = serverListDisplayLabel(server, servers); const label = serverListDisplayLabel(server, servers);
return ( return (
<tr key={server.id} style={ROW_BORDER}> <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' }}> <td style={{ padding: '10px' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{COVER_CACHE_STRATEGIES.map(s => ( {COVER_CACHE_STRATEGIES.map(s => (
@@ -338,7 +340,7 @@ export default function CoverCacheStrategySection() {
})} })}
{removedServerKeys.map(key => ( {removedServerKeys.map(key => (
<tr key={`removed-${key}`} style={ROW_BORDER}> <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} {key}
<span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span> <span style={{ marginLeft: 6, fontSize: 11 }}>({t('settings.coverCacheStrategyServerRemoved')})</span>
</td> </td>
@@ -414,6 +416,7 @@ export default function CoverCacheStrategySection() {
</div> </div>
</div> </div>
)} )}
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
); );
+1 -1
View File
@@ -19,7 +19,7 @@ export function HomeCustomizer() {
}; };
return ( return (
<div className="settings-card" style={{ padding: '4px 0' }}> <div style={{ padding: '4px 0' }}>
{sections.map(sec => ( {sections.map(sec => (
<div key={sec.id} className="sidebar-customizer-row"> <div key={sec.id} className="sidebar-customizer-row">
<span style={{ flex: 1, fontSize: 14 }}>{SECTION_LABELS[sec.id]}</span> <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 { useGlobalShortcutsStore, type GlobalAction, buildGlobalShortcut, formatGlobalShortcut } from '../../store/globalShortcutsStore';
import { useKeybindingsStore, type KeyAction, buildInAppBinding, formatBinding } from '../../store/keybindingsStore'; import { useKeybindingsStore, type KeyAction, buildInAppBinding, formatBinding } from '../../store/keybindingsStore';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
export function InputTab() { export function InputTab() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -32,6 +33,7 @@ export function InputTab() {
} }
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{IN_APP_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => { {IN_APP_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
const label = getLabel(t); const label = getLabel(t);
@@ -95,6 +97,7 @@ export function InputTab() {
); );
})} })}
</div> </div>
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -116,6 +119,7 @@ export function InputTab() {
} }
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => { {GLOBAL_SHORTCUT_ACTIONS.map(({ id: action, getLabel }) => {
const label = getLabel(t); const label = getLabel(t);
@@ -177,6 +181,7 @@ export function InputTab() {
); );
})} })}
</div> </div>
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
</> </>
+1 -1
View File
@@ -71,7 +71,7 @@ export function InstalledThemes() {
]; ];
return ( return (
<div className="settings-card"> <div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))', gap: '10px' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))', gap: '10px' }}>
{cards.map(c => { {cards.map(c => {
const isActive = active === c.id; 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 { AlertTriangle, Info, Sparkles, Wifi } from 'lucide-react';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import { MusicNetworkSection } from './musicNetwork/MusicNetworkSection'; import { MusicNetworkSection } from './musicNetwork/MusicNetworkSection';
export function IntegrationsTab() { export function IntegrationsTab() {
@@ -41,55 +43,37 @@ export function IntegrationsTab() {
> >
{t('settings.discordRichPresenceNotice')} {t('settings.discordRichPresenceNotice')}
</div> </div>
<div className="settings-toggle-row"> <SettingsGroup title={t('settings.discordRichPresence')}>
<div> <SettingsToggle
<div style={{ fontWeight: 500 }}>{t('settings.discordRichPresence')}</div> desc={t('settings.discordRichPresenceDesc')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.discordRichPresenceDesc')}</div> ariaLabel={t('settings.discordRichPresence')}
</div> checked={auth.discordRichPresence}
<label className="toggle-switch" aria-label={t('settings.discordRichPresence')}> onChange={auth.setDiscordRichPresence}
<input type="checkbox" checked={auth.discordRichPresence} onChange={e => auth.setDiscordRichPresence(e.target.checked)} /> />
<span className="toggle-track" /> </SettingsGroup>
</label>
</div>
{auth.discordRichPresence && ( {auth.discordRichPresence && (
<> <>
<div className="settings-toggle-row" style={{ padding: '4px var(--space-3) 4px var(--space-6)', fontSize: 13 }}> <SettingsGroup title={t('settings.discordCoverTitle')} desc={t('settings.discordCoverDesc')}>
<div style={{ fontWeight: 500 }}>{t('settings.discordCoverNone')}</div> <SettingsToggle
<label className="toggle-switch" aria-label={t('settings.discordCoverNone')}> label={t('settings.discordCoverNone')}
<input
type="checkbox"
checked={auth.discordCoverSource === 'none'} 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 className="settings-section-divider" />
<div style={{ paddingTop: 8 }}> <SettingsToggle
<div style={{ fontWeight: 500, fontSize: 13, marginBottom: 8 }}>{t('settings.discordTemplates')}</div> label={t('settings.discordCoverServer')}
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>{t('settings.discordTemplatesDesc')}</div> 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' }}> <div className="form-group" style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: 12 }}>{t('settings.discordTemplateName')}</label> <label style={{ fontSize: 12 }}>{t('settings.discordTemplateName')}</label>
<input <input
@@ -130,7 +114,7 @@ export function IntegrationsTab() {
placeholder="{album}" placeholder="{album}"
/> />
</div> </div>
</div> </SettingsGroup>
</> </>
)} )}
</div> </div>
@@ -142,16 +126,14 @@ export function IntegrationsTab() {
icon={<Info size={16} />} icon={<Info size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<div className="settings-toggle-row"> <SettingsGroup>
<div> <SettingsToggle
<div style={{ fontWeight: 500 }}>{t('settings.enableBandsintown')}</div> desc={t('settings.enableBandsintownDesc')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.enableBandsintownDesc')}</div> ariaLabel={t('settings.enableBandsintown')}
</div> checked={auth.enableBandsintown}
<label className="toggle-switch" aria-label={t('settings.enableBandsintown')}> onChange={auth.setEnableBandsintown}
<input type="checkbox" checked={auth.enableBandsintown} onChange={e => auth.setEnableBandsintown(e.target.checked)} /> />
<span className="toggle-track" /> </SettingsGroup>
</label>
</div>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -161,17 +143,15 @@ export function IntegrationsTab() {
icon={<Wifi size={16} />} icon={<Wifi size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<div className="settings-toggle-row"> <SettingsGroup>
<div> <SettingsToggle
<div style={{ fontWeight: 500 }}>{t('settings.nowPlayingEnabled')}</div> desc={t('settings.nowPlayingEnabledDesc')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.nowPlayingEnabledDesc')}</div> note={t('settings.nowPlayingPluginNote')}
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-secondary)', marginTop: 4 }}>{t('settings.nowPlayingPluginNote')}</div> ariaLabel={t('settings.nowPlayingEnabled')}
</div> checked={auth.nowPlayingEnabled}
<label className="toggle-switch" aria-label={t('settings.nowPlayingEnabled')}> onChange={auth.setNowPlayingEnabled}
<input type="checkbox" checked={auth.nowPlayingEnabled} onChange={e => auth.setNowPlayingEnabled(e.target.checked)} /> />
<span className="toggle-track" /> </SettingsGroup>
</label>
</div>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
</> </>
+11 -36
View File
@@ -4,6 +4,8 @@ import { Shuffle, Star } from 'lucide-react';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults'; import { MIX_MIN_RATING_FILTER_MAX_STARS } from '../../store/authStoreDefaults';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import StarRating from '../StarRating'; import StarRating from '../StarRating';
import AnalyticsStrategySection from './AnalyticsStrategySection'; 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']; 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} />} icon={<Shuffle size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}> <p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.randomMixBlacklistDesc')} {t('settings.randomMixBlacklistDesc')}
</p> </p>
@@ -83,28 +86,7 @@ export function LibraryTab() {
</button> </button>
</div> </div>
<div className="divider" style={{ margin: '1rem 0' }} /> <div style={{ fontSize: 13, fontWeight: 500, margin: '1rem 0 0.5rem', color: 'var(--text-muted)' }}>{t('settings.randomMixHardcodedTitle')}</div>
<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={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
{AUDIOBOOK_GENRES_DISPLAY.map(genre => ( {AUDIOBOOK_GENRES_DISPLAY.map(genre => (
<span key={genre} className="genre-keyword-badge" style={{ <span key={genre} className="genre-keyword-badge" style={{
@@ -116,6 +98,7 @@ export function LibraryTab() {
</span> </span>
))} ))}
</div> </div>
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -125,6 +108,7 @@ export function LibraryTab() {
icon={<Star size={16} />} icon={<Star size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<div className="settings-toggle-row"> <div className="settings-toggle-row">
<div> <div>
<div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div> <div style={{ fontWeight: 500 }}>{t('settings.ratingsSkipStarTitle')}</div>
@@ -162,25 +146,15 @@ export function LibraryTab() {
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.ratingsMixFilterTitle')}
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div> desc={t('settings.ratingsMixFilterDesc', {
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.ratingsMixFilterDesc', {
mix: t('sidebar.randomMix'), mix: t('sidebar.randomMix'),
albums: t('sidebar.randomAlbums'), albums: t('sidebar.randomAlbums'),
})} })}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
<input
type="checkbox"
checked={auth.mixMinRatingFilterEnabled} checked={auth.mixMinRatingFilterEnabled}
onChange={e => auth.setMixMinRatingFilterEnabled(e.target.checked)} onChange={auth.setMixMinRatingFilterEnabled}
/> />
<span className="toggle-track" />
</label>
</div>
{auth.mixMinRatingFilterEnabled && ( {auth.mixMinRatingFilterEnabled && (
<> <>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
@@ -220,6 +194,7 @@ export function LibraryTab() {
</div> </div>
</> </>
)} )}
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
</> </>
@@ -1,10 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { GripVertical, Music2 } from 'lucide-react'; import { GripVertical } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext'; import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import type { LyricsSourceId } from '../../store/authStoreTypes'; import type { LyricsSourceId } from '../../store/authStoreTypes';
import { SettingsToggle } from './SettingsToggle';
const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = { const LYRICS_SOURCE_LABEL_KEYS: Record<LyricsSourceId, string> = {
server: 'settings.lyricsSourceServer', server: 'settings.lyricsSourceServer',
@@ -98,11 +99,7 @@ export function LyricsSourcesCustomizer() {
}; };
return ( 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 }}> <p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
{t('settings.lyricsSourcesDesc')} {t('settings.lyricsSourcesDesc')}
</p> </p>
@@ -110,28 +107,19 @@ export function LyricsSourcesCustomizer() {
{/* YouLyPlus (karaoke) — independent toggle. When on it is tried first and {/* YouLyPlus (karaoke) — independent toggle. When on it is tried first and
the enabled sources below act as fallback; when off only those sources the enabled sources below act as fallback; when off only those sources
are used. YouLyPlus off + every source off = lyrics fully disabled. */} are used. YouLyPlus off + every source off = lyrics fully disabled. */}
<div className="settings-card" style={{ marginBottom: '0.75rem' }}> <div style={{ marginBottom: '0.75rem' }}>
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.lyricsYouLyPlus')}
<div style={{ fontWeight: 500 }}>{t('settings.lyricsYouLyPlus')}</div> desc={t('settings.lyricsYouLyPlusDesc')}
<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"
checked={youLyPlusEnabled} checked={youLyPlusEnabled}
onChange={e => setYouLyPlusEnabled(e.target.checked)} onChange={setYouLyPlusEnabled}
/> />
<span className="toggle-track" />
</label>
</div>
</div> </div>
<div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 0.4rem' }}> <div className="playback-rate-derived" style={{ fontSize: 12, color: 'var(--text-muted)', margin: '0 0 0.4rem' }}>
{youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')} {youLyPlusEnabled ? t('settings.lyricsSourcesFallbackHint') : t('settings.lyricsSourcesPrimaryHint')}
</div> </div>
<div <div
className="settings-card"
style={{ padding: '4px 0', marginBottom: '0.75rem' }} style={{ padding: '4px 0', marginBottom: '0.75rem' }}
ref={setContainerEl} ref={setContainerEl}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
@@ -162,18 +150,14 @@ export function LyricsSourcesCustomizer() {
</div> </div>
{/* Static-only toggle — suppresses line/word tracking in both modes. */} {/* Static-only toggle — suppresses line/word tracking in both modes. */}
<div className="settings-card" style={{ marginBottom: '0.75rem' }}> <div style={{ marginBottom: '0.75rem' }}>
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.lyricsStaticOnly')}
<div style={{ fontWeight: 500 }}>{t('settings.lyricsStaticOnly')}</div> desc={t('settings.lyricsStaticOnlyDesc')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.lyricsStaticOnlyDesc')}</div> checked={lyricsStaticOnly}
onChange={setLyricsStaticOnly}
/>
</div> </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 { AudioLines, Music2 } from 'lucide-react';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import { LyricsSourcesCustomizer } from './LyricsSourcesCustomizer'; import { LyricsSourcesCustomizer } from './LyricsSourcesCustomizer';
export function LyricsTab() { export function LyricsTab() {
@@ -15,37 +17,32 @@ export function LyricsTab() {
title={t('settings.lyricsSourcesTitle')} title={t('settings.lyricsSourcesTitle')}
icon={<Music2 size={16} />} icon={<Music2 size={16} />}
> >
<SettingsGroup>
<LyricsSourcesCustomizer /> <LyricsSourcesCustomizer />
</SettingsGroup>
</SettingsSubSection> </SettingsSubSection>
<SettingsSubSection <SettingsSubSection
title={t('settings.sidebarLyricsStyle')} title={t('settings.sidebarLyricsStyle')}
icon={<AudioLines size={16} />} icon={<AudioLines size={16} />}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> <SettingsGroup>
{(['classic', 'apple'] as const).map(style => { {(['classic', 'apple'] as const).map((style, i) => {
const key = style === 'classic' ? 'Classic' : 'Apple'; const key = style === 'classic' ? 'Classic' : 'Apple';
const other = style === 'classic' ? 'apple' : 'classic'; const other = style === 'classic' ? 'apple' : 'classic';
return ( return (
<div key={style} className="settings-card"> <div key={style}>
<div className="settings-toggle-row"> {i > 0 && <div className="settings-section-divider" />}
<div> <SettingsToggle
<div style={{ fontWeight: 500 }}>{t(`settings.sidebarLyricsStyle${key}` as any)}</div> label={t(`settings.sidebarLyricsStyle${key}` as any)}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t(`settings.sidebarLyricsStyle${key}Desc` as any)}</div> desc={t(`settings.sidebarLyricsStyle${key}Desc` as any)}
</div>
<label className="toggle-switch" aria-label={t(`settings.sidebarLyricsStyle${key}` as any)}>
<input
type="checkbox"
checked={sidebarLyricsStyle === style} 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>
); );
})} })}
</div> </SettingsGroup>
</SettingsSubSection> </SettingsSubSection>
</> </>
); );
+28 -36
View File
@@ -8,6 +8,8 @@ import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
import { useQueueToolbarStore } from '../../store/queueToolbarStore'; import { useQueueToolbarStore } from '../../store/queueToolbarStore';
import { useSidebarStore } from '../../store/sidebarStore'; import { useSidebarStore } from '../../store/sidebarStore';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import { ArtistLayoutCustomizer } from './ArtistLayoutCustomizer'; import { ArtistLayoutCustomizer } from './ArtistLayoutCustomizer';
import { HomeCustomizer } from './HomeCustomizer'; import { HomeCustomizer } from './HomeCustomizer';
import { PlayerBarLayoutCustomizer } from './PlayerBarLayoutCustomizer'; import { PlayerBarLayoutCustomizer } from './PlayerBarLayoutCustomizer';
@@ -37,7 +39,9 @@ export function PersonalisationTab() {
</button> </button>
} }
> >
<SettingsGroup>
<SidebarCustomizer /> <SidebarCustomizer />
</SettingsGroup>
</SettingsSubSection> </SettingsSubSection>
<SettingsSubSection <SettingsSubSection
@@ -56,7 +60,9 @@ export function PersonalisationTab() {
</button> </button>
} }
> >
<SettingsGroup>
<HomeCustomizer /> <HomeCustomizer />
</SettingsGroup>
</SettingsSubSection> </SettingsSubSection>
<SettingsSubSection <SettingsSubSection
@@ -76,7 +82,9 @@ export function PersonalisationTab() {
</button> </button>
} }
> >
<SettingsGroup>
<ArtistLayoutCustomizer /> <ArtistLayoutCustomizer />
</SettingsGroup>
</SettingsSubSection> </SettingsSubSection>
<SettingsSubSection <SettingsSubSection
@@ -84,53 +92,31 @@ export function PersonalisationTab() {
icon={<ListOrdered size={16} />} icon={<ListOrdered size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
{/* Three mutually exclusive modes — exactly one is always active, so {/* Three mutually exclusive modes — exactly one is always active, so
turning one on turns the others off; the active one cannot be turning one on turns the others off; the active one cannot be
switched off directly (ignore the uncheck). */} switched off directly (ignore the uncheck). */}
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('queue.title')}
<div style={{ fontWeight: 500 }}>{t('queue.title')}</div> desc={t('settings.queueModeQueueSub')}
<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"
checked={queueDisplayMode === 'queue'} 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-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('queue.modePlaylist')}
<div style={{ fontWeight: 500 }}>{t('queue.modePlaylist')}</div> desc={t('settings.queueModePlaylistSub')}
<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"
checked={queueDisplayMode === 'playlist'} 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-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('queue.modeTimeline')}
<div style={{ fontWeight: 500 }}>{t('queue.modeTimeline')}</div> desc={t('settings.queueModeTimelineSub')}
<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"
checked={queueDisplayMode === 'timeline'} checked={queueDisplayMode === 'timeline'}
onChange={e => { if (e.target.checked) setQueueDisplayMode('timeline'); }} onChange={c => { if (c) setQueueDisplayMode('timeline'); }}
/> />
<span className="toggle-track" /> </SettingsGroup>
</label>
</div>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -151,7 +137,9 @@ export function PersonalisationTab() {
</button> </button>
} }
> >
<SettingsGroup>
<QueueToolbarCustomizer /> <QueueToolbarCustomizer />
</SettingsGroup>
</SettingsSubSection> </SettingsSubSection>
<SettingsSubSection <SettingsSubSection
@@ -171,7 +159,9 @@ export function PersonalisationTab() {
</button> </button>
} }
> >
<SettingsGroup>
<PlaylistLayoutCustomizer /> <PlaylistLayoutCustomizer />
</SettingsGroup>
</SettingsSubSection> </SettingsSubSection>
<SettingsSubSection <SettingsSubSection
@@ -191,7 +181,9 @@ export function PersonalisationTab() {
</button> </button>
} }
> >
<SettingsGroup>
<PlayerBarLayoutCustomizer /> <PlayerBarLayoutCustomizer />
</SettingsGroup>
</SettingsSubSection> </SettingsSubSection>
</> </>
); );
@@ -35,7 +35,7 @@ export function PlayerBarLayoutCustomizer() {
const toggleItem = usePlayerBarLayoutStore(s => s.toggleItem); const toggleItem = usePlayerBarLayoutStore(s => s.toggleItem);
return ( return (
<div className="settings-card" style={{ padding: '4px 0' }}> <div style={{ padding: '4px 0' }}>
{items.map((it) => { {items.map((it) => {
const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]); const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]);
return ( return (
@@ -24,7 +24,7 @@ export function PlaylistLayoutCustomizer() {
const toggleItem = usePlaylistLayoutStore(s => s.toggleItem); const toggleItem = usePlaylistLayoutStore(s => s.toggleItem);
return ( return (
<div className="settings-card" style={{ padding: '4px 0' }}> <div style={{ padding: '4px 0' }}>
{items.map((it) => { {items.map((it) => {
const Icon = PLAYLIST_LAYOUT_ICONS[it.id]; const Icon = PLAYLIST_LAYOUT_ICONS[it.id];
const label = t(PLAYLIST_LAYOUT_LABEL_KEYS[it.id]); const label = t(PLAYLIST_LAYOUT_LABEL_KEYS[it.id]);
@@ -104,7 +104,7 @@ export function QueueToolbarCustomizer() {
}; };
return ( 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) => { {buttons.map((btn, idx) => {
const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id]; const Icon = QUEUE_TOOLBAR_BUTTON_ICONS[btn.id];
const label = t(QUEUE_TOOLBAR_LABEL_KEYS[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)', 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' }}> <div style={{ fontSize: 12, lineHeight: 1.45, marginBottom: '0.5rem' }}>
{connection === 'offline' && ( {connection === 'offline' ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, marginRight: '0.5rem' }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, color: 'var(--text-muted)' }}>
<WifiOff size={12} /> <WifiOff size={12} style={{ flexShrink: 0 }} />
{t('settings.libraryIndexServerDeferred')}
</span>
)}
{busy && (
<span style={{ color: 'var(--accent)', marginRight: '0.5rem' }}>
{t('settings.libraryIndexServerSyncing')}
</span>
)}
{phaseLabel} {phaseLabel}
</span>
) : busy ? (
<span style={{ color: 'var(--accent)' }}>
{t('settings.libraryIndexServerSyncing')} {phaseLabel}
</span>
) : (
<span style={{ color: 'var(--text-muted)' }}>{phaseLabel}</span>
)}
</div> </div>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
<button <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 { useLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
import { ALL_NAV_ITEMS } from '../../config/navItems'; import { ALL_NAV_ITEMS } from '../../config/navItems';
import { applySidebarDropReorder } from '../../utils/componentHelpers/sidebarNavReorder'; import { applySidebarDropReorder } from '../../utils/componentHelpers/sidebarNavReorder';
import { SettingsToggle } from './SettingsToggle';
type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null; type DropTarget = { idx: number; before: boolean; section: 'library' | 'system' } | null;
@@ -41,6 +42,8 @@ export function SidebarCustomizer() {
const setRandomNavMode = useAuthStore(s => s.setRandomNavMode); const setRandomNavMode = useAuthStore(s => s.setRandomNavMode);
const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop); const nowPlayingAtTop = useAuthStore(s => s.nowPlayingAtTop);
const setNowPlayingAtTop = useAuthStore(s => s.setNowPlayingAtTop); const setNowPlayingAtTop = useAuthStore(s => s.setNowPlayingAtTop);
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
const setShowLuckyMixMenu = useAuthStore(s => s.setShowLuckyMixMenu);
const luckyMixBase = useLuckyMixAvailable(); const luckyMixBase = useLuckyMixAvailable();
const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate'; const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate';
@@ -125,44 +128,35 @@ export function SidebarCustomizer() {
return ( return (
<> <>
<div className="settings-card" style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.randomNavSplitTitle')}
<div style={{ fontWeight: 500 }}>{t('settings.randomNavSplitTitle')}</div> desc={t('settings.randomNavSplitDesc')}
<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"
checked={randomNavMode === 'separate'} checked={randomNavMode === 'separate'}
onChange={e => setRandomNavMode(e.target.checked ? 'separate' : 'hub')} onChange={c => setRandomNavMode(c ? 'separate' : 'hub')}
/> />
<span className="toggle-track" /> <SettingsToggle
</label> label={t('settings.nowPlayingTopTitle')}
</div> desc={t('settings.nowPlayingTopDesc')}
<div className="settings-toggle-row" data-settings-search={t('settings.nowPlayingTopTitle')}> searchText={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"
checked={nowPlayingAtTop} 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>
<div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <div ref={containerRef} onMouseMove={handleMouseMove} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* Library block */} {/* 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> <div className="sidebar-customizer-block-label">{t('sidebar.library')}</div>
{libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))} {libraryItems.map((cfg, i) => renderRow(cfg, i, 'library'))}
</div> </div>
{/* System block */} {/* 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> <div className="sidebar-customizer-block-label">{t('sidebar.system')}</div>
{systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))} {systemItems.map((cfg, i) => renderRow(cfg, i, 'system'))}
<div className="sidebar-customizer-fixed-hint"> <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 { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes'; import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import CoverCacheStrategySection from './CoverCacheStrategySection'; import CoverCacheStrategySection from './CoverCacheStrategySection';
export function StorageTab() { export function StorageTab() {
@@ -74,9 +76,7 @@ export function StorageTab() {
icon={<FolderOpen size={16} />} icon={<FolderOpen size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}> <SettingsGroup desc={t('settings.mediaDirDesc')}>
{t('settings.mediaDirDesc')}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input <input
className="input" className="input"
@@ -104,6 +104,7 @@ export function StorageTab() {
{t('settings.mediaDirHint')} {t('settings.mediaDirHint')}
</div> </div>
)} )}
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -114,17 +115,14 @@ export function StorageTab() {
icon={<Download size={16} />} icon={<Download size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<div className="settings-toggle-row"> <SettingsGroup>
<div> <SettingsToggle
<div style={{ fontWeight: 500 }}>{t('settings.hotCacheTitle')}</div> label={t('settings.hotCacheTitle')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.hotCacheDisclaimer')}</div> desc={t('settings.hotCacheDisclaimer')}
</div> ariaLabel={t('settings.hotCacheEnabled')}
<label className="toggle-switch" aria-label={t('settings.hotCacheEnabled')}> id="hot-cache-enabled-toggle"
<input
type="checkbox"
checked={auth.hotCacheEnabled} checked={auth.hotCacheEnabled}
onChange={async e => { onChange={async enabled => {
const enabled = e.target.checked;
if (!enabled) { if (!enabled) {
await clearHotCacheDisk(mediaDir); await clearHotCacheDisk(mediaDir);
setHotCacheBytes(0); setHotCacheBytes(0);
@@ -134,11 +132,7 @@ export function StorageTab() {
refreshHotCacheSize(); refreshHotCacheSize();
} }
}} }}
id="hot-cache-enabled-toggle"
/> />
<span className="toggle-track" />
</label>
</div>
{auth.hotCacheEnabled && ( {auth.hotCacheEnabled && (
<div style={{ marginTop: '1.25rem' }}> <div style={{ marginTop: '1.25rem' }}>
@@ -186,6 +180,7 @@ export function StorageTab() {
</button> </button>
</div> </div>
)} )}
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -194,9 +189,7 @@ export function StorageTab() {
icon={<FolderOpen size={16} />} icon={<FolderOpen size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 14, lineHeight: 1.5 }}> <SettingsGroup desc={t('settings.downloadsFolderDesc')}>
{t('settings.downloadsFolderDesc')}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input <input
className="input" className="input"
@@ -220,6 +213,7 @@ export function StorageTab() {
<FolderOpen size={16} /> {t('settings.pickFolder')} <FolderOpen size={16} /> {t('settings.pickFolder')}
</button> </button>
</div> </div>
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
</> </>
+38 -60
View File
@@ -15,6 +15,8 @@ import { AboutPsysonicBrandHeader } from '../AboutPsysonicLol';
import CustomSelect from '../CustomSelect'; import CustomSelect from '../CustomSelect';
import LicensesPanel from '../LicensesPanel'; import LicensesPanel from '../LicensesPanel';
import SettingsSubSection from '../SettingsSubSection'; import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import { BackupSection } from './BackupSection'; import { BackupSection } from './BackupSection';
import { CONTRIBUTORS, MAINTAINERS } from '../../config/settingsCredits'; import { CONTRIBUTORS, MAINTAINERS } from '../../config/settingsCredits';
@@ -55,6 +57,7 @@ export function SystemTab() {
icon={<Globe size={16} />} icon={<Globe size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<div className="form-group" style={{ maxWidth: '300px' }}> <div className="form-group" style={{ maxWidth: '300px' }}>
<CustomSelect <CustomSelect
value={i18n.language} value={i18n.language}
@@ -72,6 +75,7 @@ export function SystemTab() {
]} ]}
/> />
</div> </div>
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -81,59 +85,37 @@ export function SystemTab() {
icon={<AppWindow size={16} />} icon={<AppWindow size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<div className="settings-toggle-row"> <SettingsGroup title={t('settings.groupTray')}>
<div> <SettingsToggle
<div style={{ fontWeight: 500 }}>{t('settings.showTrayIcon')}</div> label={t('settings.showTrayIcon')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showTrayIconDesc')}</div> desc={t('settings.showTrayIconDesc')}
</div> checked={auth.showTrayIcon}
<label className="toggle-switch" aria-label={t('settings.showTrayIcon')}> onChange={auth.setShowTrayIcon}
<input type="checkbox" checked={auth.showTrayIcon} onChange={e => auth.setShowTrayIcon(e.target.checked)} /> />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.minimizeToTray')}
<div style={{ fontWeight: 500 }}>{t('settings.minimizeToTray')}</div> desc={t('settings.minimizeToTrayDesc')}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.minimizeToTrayDesc')}</div> checked={auth.minimizeToTray}
</div> onChange={auth.setMinimizeToTray}
<label className="toggle-switch" aria-label={t('settings.minimizeToTray')}> />
<input type="checkbox" checked={auth.minimizeToTray} onChange={e => auth.setMinimizeToTray(e.target.checked)} /> </SettingsGroup>
<span className="toggle-track" />
</label>
</div>
{IS_LINUX && ( {IS_LINUX && (
<> <SettingsGroup title={t('settings.groupLinuxRendering')}>
<div className="settings-section-divider" /> <SettingsToggle
<div className="settings-toggle-row"> label={t('settings.linuxWebkitSmoothScroll')}
<div> desc={t('settings.linuxWebkitSmoothScrollDesc')}
<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"
checked={auth.linuxWebkitKineticScroll} 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-section-divider" />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.linuxWebkitInputForceRepaint')}
<div style={{ fontWeight: 500 }}>{t('settings.linuxWebkitInputForceRepaint')}</div> desc={t('settings.linuxWebkitInputForceRepaintDesc')}
<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"
checked={auth.linuxWebkitInputForceRepaint} checked={auth.linuxWebkitInputForceRepaint}
onChange={e => auth.setLinuxWebkitInputForceRepaint(e.target.checked)} onChange={auth.setLinuxWebkitInputForceRepaint}
/> />
<span className="toggle-track" />
</label>
</div>
{waylandTextRenderAvailable && ( {waylandTextRenderAvailable && (
<> <>
<div className="settings-section-divider" /> <div className="settings-section-divider" />
@@ -155,9 +137,10 @@ export function SystemTab() {
</div> </div>
</> </>
)} )}
</> </SettingsGroup>
)} )}
<div className="settings-section-divider" />
<SettingsGroup title={t('settings.groupClock')}>
<div className="settings-toggle-row"> <div className="settings-toggle-row">
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>{t('settings.clockFormat')}</div> <div style={{ fontWeight: 500 }}>{t('settings.clockFormat')}</div>
@@ -175,6 +158,7 @@ export function SystemTab() {
/> />
</div> </div>
</div> </div>
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -190,6 +174,7 @@ export function SystemTab() {
icon={<Sliders size={16} />} icon={<Sliders size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.loggingModeDesc')} {t('settings.loggingModeDesc')}
</div> </div>
@@ -210,6 +195,7 @@ export function SystemTab() {
</button> </button>
</div> </div>
)} )}
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
@@ -271,20 +257,12 @@ export function SystemTab() {
</div> </div>
<div className="settings-section-divider" style={{ marginTop: '1.25rem' }} /> <div className="settings-section-divider" style={{ marginTop: '1.25rem' }} />
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.showChangelogOnUpdate')}
<div style={{ fontWeight: 500 }}>{t('settings.showChangelogOnUpdate')}</div> desc={t('settings.showChangelogOnUpdateDesc')}
<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"
checked={auth.showChangelogOnUpdate} 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' }}> <div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.25rem', flexWrap: 'wrap' }}>
<button <button
@@ -62,7 +62,7 @@ export function ThemeImportSection() {
}; };
return ( return (
<div className="settings-card"> <div>
<button <button
type="button" type="button"
onClick={handleImport} onClick={handleImport}
+15 -5
View File
@@ -9,6 +9,7 @@ import { FIXED_THEMES } from './fixedThemes';
import { InstalledThemes } from './InstalledThemes'; import { InstalledThemes } from './InstalledThemes';
import { ThemeImportSection } from './ThemeImportSection'; import { ThemeImportSection } from './ThemeImportSection';
import { ThemeStoreSection } from './ThemeStoreSection'; import { ThemeStoreSection } from './ThemeStoreSection';
import { SettingsGroup } from './SettingsGroup';
/** /**
* A flat, always-visible section. The Themes tab has a single purpose, so its * 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- * the collapsible <details> SettingsSubSection used elsewhere. `data-settings-
* search` keeps each section reachable from the global settings search. * 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 ( return (
<section className="themes-section" data-settings-search={title} style={{ marginBottom: '1.75rem' }}> <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' }}> <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 ( return (
<> <>
<ThemesSection icon={<Palette size={16} />} title={t('settings.themesYourThemesTitle')}> <ThemesSection icon={<Palette size={16} />} title={t('settings.themesYourThemesTitle')} boxed>
{theme.enableThemeScheduler && ( {theme.enableThemeScheduler && (
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}> <div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
{t('settings.themeSchedulerActiveHint')} {t('settings.themeSchedulerActiveHint')}
@@ -48,8 +58,8 @@ export function ThemesTab() {
<InstalledThemes /> <InstalledThemes />
</ThemesSection> </ThemesSection>
<ThemesSection icon={<Clock size={16} />} title={t('settings.themeSchedulerTitle')}> <ThemesSection icon={<Clock size={16} />} title={t('settings.themeSchedulerTitle')} boxed>
<div className="settings-card"> <div>
<div className="settings-toggle-row"> <div className="settings-toggle-row">
<div> <div>
<div style={{ fontWeight: 500 }}>{t('settings.themeSchedulerEnable')}</div> <div style={{ fontWeight: 500 }}>{t('settings.themeSchedulerEnable')}</div>
@@ -114,7 +124,7 @@ export function ThemesTab() {
</div> </div>
</ThemesSection> </ThemesSection>
<ThemesSection icon={<Upload size={16} />} title={t('settings.themeImportTitle')}> <ThemesSection icon={<Upload size={16} />} title={t('settings.themeImportTitle')} boxed>
<ThemeImportSection /> <ThemeImportSection />
</ThemesSection> </ThemesSection>
@@ -4,6 +4,7 @@ import { AudioLines, RotateCcw } from 'lucide-react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import CustomSelect from '../../CustomSelect'; import CustomSelect from '../../CustomSelect';
import SettingsSubSection from '../../SettingsSubSection'; import SettingsSubSection from '../../SettingsSubSection';
import { SettingsGroup } from '../SettingsGroup';
import { useAuthStore } from '../../../store/authStore'; import { useAuthStore } from '../../../store/authStore';
import { IS_MACOS } from '../../../utils/platform'; import { IS_MACOS } from '../../../utils/platform';
import { buildAudioDeviceSelectOptions } from '../../../utils/audio/audioDeviceLabels'; import { buildAudioDeviceSelectOptions } from '../../../utils/audio/audioDeviceLabels';
@@ -43,6 +44,7 @@ export function AudioOutputDeviceSection({
icon={<AudioLines size={16} />} icon={<AudioLines size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<SettingsGroup>
{IS_MACOS ? ( {IS_MACOS ? (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}> <div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}>
{t('settings.audioOutputDeviceMacNotice')} {t('settings.audioOutputDeviceMacNotice')}
@@ -86,6 +88,7 @@ export function AudioOutputDeviceSection({
</div> </div>
</> </>
)} )}
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
); );
@@ -4,6 +4,7 @@ import type { TFunction } from 'i18next';
import { useAuthStore } from '../../../store/authStore'; import { useAuthStore } from '../../../store/authStore';
import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from '../../../store/authStoreDefaults'; import { DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB } from '../../../store/authStoreDefaults';
import { LoudnessLufsButtonGroup } from '../LoudnessLufsButtonGroup'; import { LoudnessLufsButtonGroup } from '../LoudnessLufsButtonGroup';
import { SettingsGroup } from '../SettingsGroup';
interface Props { interface Props {
preAnalysisEffectiveDb: number; preAnalysisEffectiveDb: number;
@@ -28,9 +29,10 @@ export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
const auth = useAuthStore(); const auth = useAuthStore();
return ( return (
<div className="settings-group"> <SettingsGroup
<div className="settings-group-title">{t('settings.normalization', { defaultValue: 'Normalization' })}</div> title={t('settings.normalization', { defaultValue: 'Normalization' })}
<div className="settings-group-desc">{t('settings.normalizationDesc')}</div> desc={t('settings.normalizationDesc')}
>
<div className="settings-segmented" style={{ marginBottom: auth.normalizationEngine === 'off' ? 0 : '0.85rem' }}> <div className="settings-segmented" style={{ marginBottom: auth.normalizationEngine === 'off' ? 0 : '0.85rem' }}>
<button <button
type="button" type="button"
@@ -176,6 +178,6 @@ export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
<div className="settings-norm-note">{t('settings.loudnessFirstPlayNote')}</div> <div className="settings-norm-note">{t('settings.loudnessFirstPlayNote')}</div>
</div> </div>
)} )}
</div> </SettingsGroup>
); );
} }
@@ -6,6 +6,8 @@ import {
setTransitionMode, setTransitionMode,
type TransitionMode, type TransitionMode,
} from '../../../utils/playback/playbackTransition'; } from '../../../utils/playback/playbackTransition';
import { SettingsGroup } from '../SettingsGroup';
import { SettingsToggle } from '../SettingsToggle';
interface Props { interface Props {
t: TFunction; t: TFunction;
@@ -35,10 +37,7 @@ export function PlaybackBehaviorBlock({ t }: Props) {
return ( return (
<> <>
<div className="settings-group"> <SettingsGroup title={t('settings.transitionsTitle')} desc={t('settings.transitionsDesc')}>
<div className="settings-group-title">{t('settings.transitionsTitle')}</div>
<div className="settings-group-desc">{t('settings.transitionsDesc')}</div>
<div className="settings-segmented"> <div className="settings-segmented">
{transitions.map(item => ( {transitions.map(item => (
<button <button
@@ -74,27 +73,16 @@ export function PlaybackBehaviorBlock({ t }: Props) {
{t('settings.autoDjDesc')} {t('settings.autoDjDesc')}
</div> </div>
)} )}
</div> </SettingsGroup>
<div className="settings-group"> <SettingsGroup title={t('settings.queueBehaviourTitle')}>
<div className="settings-group-title">{t('settings.queueBehaviourTitle')}</div> <SettingsToggle
label={t('settings.preservePlayNextOrder')}
<div className="settings-toggle-row"> desc={t('settings.preservePlayNextOrderDesc')}
<div> checked={auth.preservePlayNextOrder}
<div style={{ fontWeight: 500 }}> onChange={auth.setPreservePlayNextOrder}
{t('settings.preservePlayNextOrder')} />
</div> </SettingsGroup>
<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>
</> </>
); );
} }
@@ -22,6 +22,7 @@ import { usePlaybackRateStore } from '../../../store/playbackRateStore';
import { useOrbitStore } from '../../../store/orbitStore'; import { useOrbitStore } from '../../../store/orbitStore';
import { useAuthStore } from '../../../store/authStore'; import { useAuthStore } from '../../../store/authStore';
import { isOrbitPlaybackSyncActive } from '../../../utils/orbit'; import { isOrbitPlaybackSyncActive } from '../../../utils/orbit';
import { SettingsToggle } from '../SettingsToggle';
interface Props { interface Props {
t: TFunction; t: TFunction;
@@ -109,22 +110,12 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
onWheel={compact ? handleWheelSpeed : undefined} onWheel={compact ? handleWheelSpeed : undefined}
> >
{showEnable && ( {showEnable && (
<div className="settings-toggle-row"> <SettingsToggle
<div> label={t('settings.playbackRateEnabled')}
<div style={{ fontWeight: 500 }}>{t('settings.playbackRateEnabled')}</div> desc={t('settings.playbackRateEnabledDesc')}
<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"
checked={enabled} checked={enabled}
onChange={e => setEnabled(e.target.checked)} onChange={setEnabled}
/> />
<span className="toggle-track" />
</label>
</div>
)} )}
{(!showEnable || enabled) && ( {(!showEnable || enabled) && (
@@ -5,6 +5,8 @@ import { useAuthStore } from '../../../store/authStore';
import { TRACK_PREVIEW_LOCATIONS } from '../../../store/authStoreDefaults'; import { TRACK_PREVIEW_LOCATIONS } from '../../../store/authStoreDefaults';
import type { TrackPreviewLocation } from '../../../store/authStoreTypes'; import type { TrackPreviewLocation } from '../../../store/authStoreTypes';
import SettingsSubSection from '../../SettingsSubSection'; import SettingsSubSection from '../../SettingsSubSection';
import { SettingsGroup } from '../SettingsGroup';
import { SettingsToggle } from '../SettingsToggle';
interface Props { interface Props {
t: TFunction; t: TFunction;
@@ -26,21 +28,13 @@ export function TrackPreviewsSection({ t }: Props) {
icon={<Play size={16} />} icon={<Play size={16} />}
> >
<div className="settings-card"> <div className="settings-card">
<div className="settings-toggle-row"> <SettingsGroup>
<div> <SettingsToggle
<div style={{ fontWeight: 500 }}> label={t('settings.trackPreviewsToggle')}
{t('settings.trackPreviewsToggle')} desc={t('settings.trackPreviewsDesc')}
</div> checked={auth.trackPreviewsEnabled}
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}> onChange={auth.setTrackPreviewsEnabled}
{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>
{auth.trackPreviewsEnabled && ( {auth.trackPreviewsEnabled && (
<> <>
@@ -123,6 +117,7 @@ export function TrackPreviewsSection({ t }: Props) {
</div> </div>
</> </>
)} )}
</SettingsGroup>
</div> </div>
</SettingsSubSection> </SettingsSubSection>
); );
@@ -71,13 +71,13 @@ export function ConnectProviderForm({
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> <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 => { {available.map(preset => {
const id = preset.manifest.presetId; const id = preset.manifest.presetId;
const isExpanded = expanded === id; const isExpanded = expanded === id;
const isBusy = busy === id; const isBusy = busy === id;
return ( 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={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{ flexShrink: 0 }} aria-hidden="true">{renderPresetIcon(preset.manifest.icon, 18)}</div> <div style={{ flexShrink: 0 }} aria-hidden="true">{renderPresetIcon(preset.manifest.icon, 18)}</div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import CustomSelect from '../../CustomSelect'; import CustomSelect from '../../CustomSelect';
import { SettingsGroup } from '../SettingsGroup';
import type { Account } from '../../../music-network'; import type { Account } from '../../../music-network';
/** /**
@@ -26,6 +27,7 @@ export function EnrichmentPrimarySelect({
]; ];
return ( return (
<SettingsGroup title={t('musicNetwork.primaryLabel')}>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -33,15 +35,9 @@ export function EnrichmentPrimarySelect({
justifyContent: 'space-between', justifyContent: 'space-between',
gap: '1rem', gap: '1rem',
flexWrap: 'wrap', flexWrap: 'wrap',
padding: '0.75rem 1rem',
borderRadius: '10px',
border: '1px solid var(--border)',
}} }}
> >
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0, fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.primaryDesc')}</div>
<div style={{ fontWeight: 500 }}>{t('musicNetwork.primaryLabel')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.primaryDesc')}</div>
</div>
<CustomSelect <CustomSelect
value={primaryId ?? ''} value={primaryId ?? ''}
options={options} options={options}
@@ -49,5 +45,6 @@ export function EnrichmentPrimarySelect({
style={{ minWidth: 180 }} style={{ minWidth: 180 }}
/> />
</div> </div>
</SettingsGroup>
); );
} }
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Share2 } from 'lucide-react'; import { Share2 } from 'lucide-react';
import SettingsSubSection from '../../SettingsSubSection'; import SettingsSubSection from '../../SettingsSubSection';
import { SettingsGroup } from '../SettingsGroup';
import { showToast } from '../../../utils/ui/toast'; import { showToast } from '../../../utils/ui/toast';
import { useAuthStore } from '../../../store/authStore'; import { useAuthStore } from '../../../store/authStore';
import { import {
@@ -77,38 +78,24 @@ export function MusicNetworkSection() {
{t('musicNetwork.desc')} {t('musicNetwork.desc')}
</p> </p>
<div <SettingsGroup title={t('musicNetwork.masterToggle')}>
style={{ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem' }}>
display: 'flex', <div style={{ minWidth: 0, fontSize: 12, color: 'var(--text-muted)' }}>{t('musicNetwork.masterToggleDesc')}</div>
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>
<label className="toggle-switch" style={{ flexShrink: 0 }} aria-label={t('musicNetwork.masterToggle')}> <label className="toggle-switch" style={{ flexShrink: 0 }} aria-label={t('musicNetwork.masterToggle')}>
<input type="checkbox" checked={scrobblingMasterEnabled} onChange={e => setMaster(e.target.checked)} /> <input type="checkbox" checked={scrobblingMasterEnabled} onChange={e => setMaster(e.target.checked)} />
<span className="toggle-track" /> <span className="toggle-track" />
</label> </label>
</div> </div>
</SettingsGroup>
<div style={{ marginTop: '0.75rem' }}>
<EnrichmentPrimarySelect <EnrichmentPrimarySelect
accounts={accounts} accounts={accounts}
primaryId={enrichmentPrimaryId} primaryId={enrichmentPrimaryId}
onChange={setPrimary} onChange={setPrimary}
/> />
</div>
{accounts.length > 0 && ( {accounts.length > 0 && (
<> <SettingsGroup>
<div className="settings-section-divider" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{accounts.map(account => ( {accounts.map(account => (
<ScrobbleDestinationCard <ScrobbleDestinationCard
@@ -120,14 +107,14 @@ export function MusicNetworkSection() {
/> />
))} ))}
</div> </div>
<MalojaProxyWarning accounts={accounts} /> <MalojaProxyWarning accounts={accounts} />
</> </SettingsGroup>
)} )}
<div className="settings-section-divider" /> <div style={{ marginTop: 'var(--space-3)' }}>
<ConnectProviderForm connectedPresetIds={connectedPresetIds} onConnect={connect} /> <ConnectProviderForm connectedPresetIds={connectedPresetIds} onConnect={connect} />
</div> </div>
</div>
</SettingsSubSection> </SettingsSubSection>
); );
} }
+8 -4
View File
@@ -138,6 +138,11 @@ export const settings = {
eqAutoEqRateLimit: 'GitHub Rate-Limit erreicht — in einer Minute erneut versuchen', eqAutoEqRateLimit: 'GitHub Rate-Limit erreicht — in einer Minute erneut versuchen',
eqAutoEqFetchError: 'EQ-Profil konnte nicht geladen werden', eqAutoEqFetchError: 'EQ-Profil konnte nicht geladen werden',
behavior: 'App-Verhalten', behavior: 'App-Verhalten',
groupTray: 'Tray',
groupLinuxRendering: 'Linux-Rendering',
groupClock: 'Uhr',
groupDisplay: 'Anzeige',
groupWindow: 'Fenster',
cacheTitle: 'Max. Speichergröße', cacheTitle: 'Max. Speichergröße',
cacheDesc: 'Browser-Cache für Cover und Künstlerbilder (IndexedDB). Wenn voll, werden die ältesten Einträge automatisch entfernt.', cacheDesc: 'Browser-Cache für Cover und Künstlerbilder (IndexedDB). Wenn voll, werden die ältesten Einträge automatisch entfernt.',
cacheUsedImages: 'Bilder:', 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.', 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', hiResTitle: 'Native Hi-Res-Wiedergabe',
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren', 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', 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.', 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', showOrbitTrigger: '„Orbit" im Header zeigen',
@@ -249,8 +254,8 @@ export const settings = {
linuxWaylandTextRenderSharp: 'Scharf (CPU-freundlich)', linuxWaylandTextRenderSharp: 'Scharf (CPU-freundlich)',
linuxWaylandTextRenderGpu: 'GPU zuerst', linuxWaylandTextRenderGpu: 'GPU zuerst',
linuxWaylandTextRenderMinimal: 'Minimal (Standard-CSS-Glättung)', linuxWaylandTextRenderMinimal: 'Minimal (Standard-CSS-Glättung)',
discordCoverSource: 'Cover-Quelle', discordCoverTitle: 'Cover-Quelle',
discordCoverSourceDesc: 'Woher das Album-Cover für dein Discord-Profil geladen wird.', discordCoverDesc: 'Woher das Cover auf deinem Discord-Profil stammt.',
discordCoverNone: 'Keine (nur App-Symbol)', discordCoverNone: 'Keine (nur App-Symbol)',
discordCoverServer: 'Server (über Album-Info)', discordCoverServer: 'Server (über Album-Info)',
discordCoverApple: 'Apple Music', discordCoverApple: 'Apple Music',
@@ -314,7 +319,6 @@ export const settings = {
libraryIndexServerListTitle: 'Indizierte Server', libraryIndexServerListTitle: 'Indizierte Server',
libraryIndexAllExcluded: 'Alle Server sind von der Synchronisation ausgeschlossen. Index wieder aktivieren oder Server hinzufügen.', libraryIndexAllExcluded: 'Alle Server sind von der Synchronisation ausgeschlossen. Index wieder aktivieren oder Server hinzufügen.',
libraryIndexServerOffline: 'Server offline — Synchronisation verschoben', libraryIndexServerOffline: 'Server offline — Synchronisation verschoben',
libraryIndexServerDeferred: 'Verschoben',
libraryIndexServerSyncing: 'Synchronisiert…', libraryIndexServerSyncing: 'Synchronisiert…',
libraryIndexFullResync: 'Vollständige Neusynchronisation', libraryIndexFullResync: 'Vollständige Neusynchronisation',
libraryIndexDeltaSync: 'Delta-Synchronisation', libraryIndexDeltaSync: 'Delta-Synchronisation',
+8 -4
View File
@@ -138,6 +138,11 @@ export const settings = {
eqAutoEqRateLimit: 'GitHub rate limit reached — try again in a minute', eqAutoEqRateLimit: 'GitHub rate limit reached — try again in a minute',
eqAutoEqFetchError: 'Failed to fetch EQ profile', eqAutoEqFetchError: 'Failed to fetch EQ profile',
behavior: 'App Behavior', behavior: 'App Behavior',
groupTray: 'Tray',
groupLinuxRendering: 'Linux rendering',
groupClock: 'Clock',
groupDisplay: 'Display',
groupWindow: 'Window',
cacheTitle: 'Max. Storage Size', cacheTitle: 'Max. Storage Size',
cacheDesc: 'In-browser cache for cover art and artist images (IndexedDB). Oldest entries are removed automatically when space runs low.', cacheDesc: 'In-browser cache for cover art and artist images (IndexedDB). Oldest entries are removed automatically when space runs low.',
cacheUsedImages: 'Images:', 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.', 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', hiResTitle: 'Native Hi-Res Playback',
hiResEnabled: 'Enable 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', 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.', 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', showOrbitTrigger: 'Show "Orbit" in the header',
@@ -252,8 +257,8 @@ export const settings = {
linuxWaylandTextRenderSharp: 'Sharp (CPU-friendly)', linuxWaylandTextRenderSharp: 'Sharp (CPU-friendly)',
linuxWaylandTextRenderGpu: 'GPU-first', linuxWaylandTextRenderGpu: 'GPU-first',
linuxWaylandTextRenderMinimal: 'Minimal (default CSS smoothing)', linuxWaylandTextRenderMinimal: 'Minimal (default CSS smoothing)',
discordCoverSource: 'Cover art source', discordCoverTitle: 'Cover art source',
discordCoverSourceDesc: 'Where to fetch album artwork shown on your Discord profile.', discordCoverDesc: 'Where the album artwork on your Discord profile comes from.',
discordCoverNone: 'None (app icon only)', discordCoverNone: 'None (app icon only)',
discordCoverServer: 'Server (via album info)', discordCoverServer: 'Server (via album info)',
discordCoverApple: 'Apple Music', discordCoverApple: 'Apple Music',
@@ -317,7 +322,6 @@ export const settings = {
libraryIndexServerListTitle: 'Indexed servers', libraryIndexServerListTitle: 'Indexed servers',
libraryIndexAllExcluded: 'All servers are excluded from sync. Re-enable the index or add a server.', libraryIndexAllExcluded: 'All servers are excluded from sync. Re-enable the index or add a server.',
libraryIndexServerOffline: 'Server offline — sync deferred', libraryIndexServerOffline: 'Server offline — sync deferred',
libraryIndexServerDeferred: 'Deferred',
libraryIndexServerSyncing: 'Syncing…', libraryIndexServerSyncing: 'Syncing…',
libraryIndexFullResync: 'Full resync', libraryIndexFullResync: 'Full resync',
libraryIndexDeltaSync: 'Delta sync', 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', eqAutoEqRateLimit: 'Límite de GitHub alcanzado — intenta de nuevo en un minuto',
eqAutoEqFetchError: 'Error al obtener perfil EQ', eqAutoEqFetchError: 'Error al obtener perfil EQ',
behavior: 'Comportamiento de la App', behavior: 'Comportamiento de la App',
groupTray: 'Bandeja',
groupLinuxRendering: 'Renderizado Linux',
groupClock: 'Reloj',
groupDisplay: 'Visualización',
groupWindow: 'Ventana',
cacheTitle: 'Tamaño Máx. de Almacenamiento', 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.', 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:', 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.', 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', hiResTitle: 'Reproducción Nativa Hi-Res',
hiResEnabled: 'Habilitar 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', 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.', 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', showOrbitTrigger: 'Mostrar "Orbit" en la cabecera',
@@ -248,8 +253,8 @@ export const settings = {
linuxWaylandTextRenderSharp: 'Nítido (más CPU)', linuxWaylandTextRenderSharp: 'Nítido (más CPU)',
linuxWaylandTextRenderGpu: 'Prioridad GPU', linuxWaylandTextRenderGpu: 'Prioridad GPU',
linuxWaylandTextRenderMinimal: 'Mínimo (suavizado CSS por defecto)', linuxWaylandTextRenderMinimal: 'Mínimo (suavizado CSS por defecto)',
discordCoverSource: 'Fuente de portada', discordCoverTitle: 'Fuente de la carátula',
discordCoverSourceDesc: 'De dónde obtener la portada del álbum para tu perfil de Discord.', discordCoverDesc: 'De dónde proviene la carátula mostrada en tu perfil de Discord.',
discordCoverNone: 'Ninguna (solo icono de la app)', discordCoverNone: 'Ninguna (solo icono de la app)',
discordCoverServer: 'Servidor (vía info del álbum)', discordCoverServer: 'Servidor (vía info del álbum)',
discordCoverApple: 'Apple Music', discordCoverApple: 'Apple Music',
@@ -312,7 +317,6 @@ export const settings = {
libraryIndexServerListTitle: 'Servidores indexados', libraryIndexServerListTitle: 'Servidores indexados',
libraryIndexAllExcluded: 'Todos los servidores están excluidos de la sincronización. Vuelve a activar el índice o añade un servidor.', 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', libraryIndexServerOffline: 'Servidor sin conexión — sincronización aplazada',
libraryIndexServerDeferred: 'Aplazada',
libraryIndexServerSyncing: 'Sincronizando…', libraryIndexServerSyncing: 'Sincronizando…',
libraryIndexFullResync: 'Resincronización completa', libraryIndexFullResync: 'Resincronización completa',
libraryIndexDeltaSync: 'Sincronización delta', libraryIndexDeltaSync: 'Sincronización delta',
+8 -4
View File
@@ -137,6 +137,11 @@ export const settings = {
eqAutoEqRateLimit: 'Limite GitHub atteinte — réessayez dans une minute', eqAutoEqRateLimit: 'Limite GitHub atteinte — réessayez dans une minute',
eqAutoEqFetchError: 'Impossible de charger le profil EQ', eqAutoEqFetchError: 'Impossible de charger le profil EQ',
behavior: 'Comportement de l\'application', behavior: 'Comportement de l\'application',
groupTray: 'Zone de notification',
groupLinuxRendering: 'Rendu Linux',
groupClock: 'Horloge',
groupDisplay: 'Affichage',
groupWindow: 'Fenêtre',
cacheTitle: 'Taille max. du stockage', 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.', 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 :', 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.', 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', hiResTitle: 'Lecture haute résolution native',
hiResEnabled: 'Activer la 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', 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.', 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', showOrbitTrigger: 'Afficher « Orbit » dans l\'en-tête',
@@ -236,8 +241,8 @@ export const settings = {
discordRichPresence: 'Discord Rich Presence', discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.', 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.', 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', discordCoverTitle: 'Source de la pochette',
discordCoverSourceDesc: 'D\'où récupérer la pochette pour votre profil Discord.', discordCoverDesc: 'D\'où provient la pochette affichée sur votre profil Discord.',
discordCoverNone: 'Aucune (icône de l\'app uniquement)', discordCoverNone: 'Aucune (icône de l\'app uniquement)',
discordCoverServer: 'Serveur (via infos album)', discordCoverServer: 'Serveur (via infos album)',
discordCoverApple: 'Apple Music', discordCoverApple: 'Apple Music',
@@ -300,7 +305,6 @@ export const settings = {
libraryIndexServerListTitle: 'Serveurs indexés', libraryIndexServerListTitle: 'Serveurs indexés',
libraryIndexAllExcluded: 'Tous les serveurs sont exclus de la synchronisation. Réactivez lindex ou ajoutez un serveur.', libraryIndexAllExcluded: 'Tous les serveurs sont exclus de la synchronisation. Réactivez lindex ou ajoutez un serveur.',
libraryIndexServerOffline: 'Serveur hors ligne — synchronisation reportée', libraryIndexServerOffline: 'Serveur hors ligne — synchronisation reportée',
libraryIndexServerDeferred: 'Reportée',
libraryIndexServerSyncing: 'Synchronisation…', libraryIndexServerSyncing: 'Synchronisation…',
libraryIndexFullResync: 'Resynchronisation complète', libraryIndexFullResync: 'Resynchronisation complète',
libraryIndexDeltaSync: 'Synchronisation delta', 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', eqAutoEqRateLimit: 'GitHub-hastighetsgrense nådd - prøv igjen om ett minutt',
eqAutoEqFetchError: 'Kunne ikke hente EQ-profil', eqAutoEqFetchError: 'Kunne ikke hente EQ-profil',
behavior: 'App-oppførsel', behavior: 'App-oppførsel',
groupTray: 'Systemkurv',
groupLinuxRendering: 'Linux-gjengivelse',
groupClock: 'Klokke',
groupDisplay: 'Visning',
groupWindow: 'Vindu',
cacheTitle: 'Maks. lagringsstørrelse', cacheTitle: 'Maks. lagringsstørrelse',
cacheDesc: 'Nettlesercache for plateomslag og artistbilder (IndexedDB). Når den er full, fjernes de eldste oppføringene automatisk.', 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', 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.', 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', hiResTitle: 'Innebygd hi-res-avspilling',
hiResEnabled: 'Aktiver 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', 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.', 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', showOrbitTrigger: 'Vis «Orbit» i toppen',
@@ -235,8 +240,8 @@ export const settings = {
discordRichPresence: 'Discord Rich Presence', discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.', 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.', 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', discordCoverTitle: 'Omslagskilde',
discordCoverSourceDesc: 'Hvor albumcoveret for Discord-profilen din hentes fra.', discordCoverDesc: 'Hvor albumomslaget på Discord-profilen din kommer fra.',
discordCoverNone: 'Ingen (kun app-ikon)', discordCoverNone: 'Ingen (kun app-ikon)',
discordCoverServer: 'Server (via albuminfo)', discordCoverServer: 'Server (via albuminfo)',
discordCoverApple: 'Apple Music', discordCoverApple: 'Apple Music',
@@ -299,7 +304,6 @@ export const settings = {
libraryIndexServerListTitle: 'Indekserte servere', libraryIndexServerListTitle: 'Indekserte servere',
libraryIndexAllExcluded: 'Alle servere er ekskludert fra synkronisering. Aktiver indeksen igjen eller legg til en server.', libraryIndexAllExcluded: 'Alle servere er ekskludert fra synkronisering. Aktiver indeksen igjen eller legg til en server.',
libraryIndexServerOffline: 'Server offline — synkronisering utsatt', libraryIndexServerOffline: 'Server offline — synkronisering utsatt',
libraryIndexServerDeferred: 'Utsatt',
libraryIndexServerSyncing: 'Synkroniserer…', libraryIndexServerSyncing: 'Synkroniserer…',
libraryIndexFullResync: 'Full resynkronisering', libraryIndexFullResync: 'Full resynkronisering',
libraryIndexDeltaSync: 'Delta-synkronisering', libraryIndexDeltaSync: 'Delta-synkronisering',
+8 -4
View File
@@ -137,6 +137,11 @@ export const settings = {
eqAutoEqRateLimit: 'GitHub limiet bereikt — probeer over een minuut opnieuw', eqAutoEqRateLimit: 'GitHub limiet bereikt — probeer over een minuut opnieuw',
eqAutoEqFetchError: 'EQ-profiel kon niet worden geladen', eqAutoEqFetchError: 'EQ-profiel kon niet worden geladen',
behavior: 'App-gedrag', behavior: 'App-gedrag',
groupTray: 'Systeemvak',
groupLinuxRendering: 'Linux-rendering',
groupClock: 'Klok',
groupDisplay: 'Weergave',
groupWindow: 'Venster',
cacheTitle: 'Max. opslaggrootte', cacheTitle: 'Max. opslaggrootte',
cacheDesc: 'Browsercache voor albumhoezen en artiestafbeeldingen (IndexedDB). Als de cache vol is, worden de oudste items automatisch verwijderd.', cacheDesc: 'Browsercache voor albumhoezen en artiestafbeeldingen (IndexedDB). Als de cache vol is, worden de oudste items automatisch verwijderd.',
cacheUsedImages: 'Afbeeldingen:', 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.', 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', hiResTitle: 'Natieve hi-res-weergave',
hiResEnabled: 'Natieve hi-res-weergave inschakelen', 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', showArtistImages: 'Artiestafbeeldingen weergeven',
showArtistImagesDesc: 'Laadt en toont artiestafbeeldingen in het artiestenoverzicht. Standaard uitgeschakeld om server-I/O en netwerkbelasting bij grote bibliotheken te beperken.', 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', showOrbitTrigger: '"Orbit" in de kop tonen',
@@ -236,8 +241,8 @@ export const settings = {
discordRichPresence: 'Discord Rich Presence', discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.', 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.', 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', discordCoverTitle: 'Bron albumhoes',
discordCoverSourceDesc: 'Waar de albumhoes voor je Discord-profiel vandaan komt.', discordCoverDesc: 'Waar de albumhoes op je Discord-profiel vandaan komt.',
discordCoverNone: 'Geen (alleen app-icoon)', discordCoverNone: 'Geen (alleen app-icoon)',
discordCoverServer: 'Server (via albuminfo)', discordCoverServer: 'Server (via albuminfo)',
discordCoverApple: 'Apple Music', discordCoverApple: 'Apple Music',
@@ -300,7 +305,6 @@ export const settings = {
libraryIndexServerListTitle: 'Geïndexeerde servers', libraryIndexServerListTitle: 'Geïndexeerde servers',
libraryIndexAllExcluded: 'Alle servers zijn uitgesloten van synchronisatie. Schakel de index weer in of voeg een server toe.', libraryIndexAllExcluded: 'Alle servers zijn uitgesloten van synchronisatie. Schakel de index weer in of voeg een server toe.',
libraryIndexServerOffline: 'Server offline — synchronisatie uitgesteld', libraryIndexServerOffline: 'Server offline — synchronisatie uitgesteld',
libraryIndexServerDeferred: 'Uitgesteld',
libraryIndexServerSyncing: 'Synchroniseren…', libraryIndexServerSyncing: 'Synchroniseren…',
libraryIndexFullResync: 'Volledige resync', libraryIndexFullResync: 'Volledige resync',
libraryIndexDeltaSync: 'Delta-sync', 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', eqAutoEqRateLimit: 'Limita ratei GitHub a fost atinsă — încearcă din nou într-un minut',
eqAutoEqFetchError: 'Nu s-a putut lua profilul EQ', eqAutoEqFetchError: 'Nu s-a putut lua profilul EQ',
behavior: 'Comportamentul Aplicației', behavior: 'Comportamentul Aplicației',
groupTray: 'Bară de sistem',
groupLinuxRendering: 'Randare Linux',
groupClock: 'Ceas',
groupDisplay: 'Afișare',
groupWindow: 'Fereastră',
cacheTitle: 'Spațiu de stocare maxim', 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.', 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:', 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.', 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', hiResTitle: 'Playback Hi-Res nativ',
hiResEnabled: 'Pornește playback-ul 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', 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.', 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', showOrbitTrigger: 'Afișează "Orbit" în antet',
@@ -251,8 +256,8 @@ export const settings = {
linuxWaylandTextRenderSharp: 'Ascuțit (prietenos cu CPU)', linuxWaylandTextRenderSharp: 'Ascuțit (prietenos cu CPU)',
linuxWaylandTextRenderGpu: 'Prioritate GPU', linuxWaylandTextRenderGpu: 'Prioritate GPU',
linuxWaylandTextRenderMinimal: 'Minim (netezire CSS implicită)', linuxWaylandTextRenderMinimal: 'Minim (netezire CSS implicită)',
discordCoverSource: 'Sursa artei de coper', discordCoverTitle: 'Sursa coperții',
discordCoverSourceDesc: 'De unde să fie preluată arta de album afișată pe profilul tău de Discord.', discordCoverDesc: 'De unde provine coperta afișată pe profilul tău Discord.',
discordCoverNone: 'Niciuna (doar iconița aplicației)', discordCoverNone: 'Niciuna (doar iconița aplicației)',
discordCoverServer: 'Server (prin informații album)', discordCoverServer: 'Server (prin informații album)',
discordCoverApple: 'Apple Music', discordCoverApple: 'Apple Music',
@@ -316,7 +321,6 @@ export const settings = {
libraryIndexServerListTitle: 'Servere indexate', libraryIndexServerListTitle: 'Servere indexate',
libraryIndexAllExcluded: 'Toate serverele sunt excluse din sincronizare. Reactivează indexul sau adaugă un server.', libraryIndexAllExcluded: 'Toate serverele sunt excluse din sincronizare. Reactivează indexul sau adaugă un server.',
libraryIndexServerOffline: 'Server offline — sincronizare amânată', libraryIndexServerOffline: 'Server offline — sincronizare amânată',
libraryIndexServerDeferred: 'Amânată',
libraryIndexServerSyncing: 'Se sincronizează…', libraryIndexServerSyncing: 'Se sincronizează…',
libraryIndexFullResync: 'Resincronizare completă', libraryIndexFullResync: 'Resincronizare completă',
libraryIndexDeltaSync: 'Sincronizare delta', libraryIndexDeltaSync: 'Sincronizare delta',
+8 -4
View File
@@ -137,6 +137,11 @@ export const settings = {
eqAutoEqRateLimit: 'Лимит GitHub — попробуйте через минуту', eqAutoEqRateLimit: 'Лимит GitHub — попробуйте через минуту',
eqAutoEqFetchError: 'Не удалось загрузить профиль EQ', eqAutoEqFetchError: 'Не удалось загрузить профиль EQ',
behavior: 'Поведение', behavior: 'Поведение',
groupTray: 'Трей',
groupLinuxRendering: 'Отрисовка (Linux)',
groupClock: 'Часы',
groupDisplay: 'Отображение',
groupWindow: 'Окно',
cacheTitle: 'Макс. размер кэша', cacheTitle: 'Макс. размер кэша',
cacheDesc: cacheDesc:
'Кэш обложек и фото исполнителей в хранилище приложения (IndexedDB). При нехватке места старые записи удаляются автоматически.', 'Кэш обложек и фото исполнителей в хранилище приложения (IndexedDB). При нехватке места старые записи удаляются автоматически.',
@@ -210,7 +215,7 @@ export const settings = {
audioOutputDeviceMacNotice: 'В macOS воспроизведение в настоящее время всегда следует за системным устройством вывода по техническим причинам. Измените цель через Системные настройки → Звук или значок динамика в строке меню. Причина: CoreAudio запрашивает разрешение на использование микрофона при открытии не-стандартного потока — мы избегаем этого, всегда используя системный вывод по умолчанию.', audioOutputDeviceMacNotice: 'В macOS воспроизведение в настоящее время всегда следует за системным устройством вывода по техническим причинам. Измените цель через Системные настройки → Звук или значок динамика в строке меню. Причина: CoreAudio запрашивает разрешение на использование микрофона при открытии не-стандартного потока — мы избегаем этого, всегда используя системный вывод по умолчанию.',
hiResTitle: 'Нативное воспроизведение Hi-Res', hiResTitle: 'Нативное воспроизведение Hi-Res',
hiResEnabled: 'Включить нативное Hi-Res воспроизведение', hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).", hiResDesc: "Воспроизводит каждый трек с его исходной частотой дискретизации вместо передискретизации всего в 44,1 кГц, переключая устройство вывода под частоту файла (88,2 кГц и выше). Включайте только если оборудование и сеть надёжно справляются с высокими частотами.",
showArtistImages: 'Фото исполнителей', showArtistImages: 'Фото исполнителей',
showArtistImagesDesc: showArtistImagesDesc:
'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.', 'Показывать обложки в разделе «Исполнители». По умолчанию выключено — меньше нагрузки на диск и сеть.',
@@ -255,8 +260,8 @@ export const settings = {
discordRichPresenceDesc: discordRichPresenceDesc:
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.', 'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
discordRichPresenceNotice: 'Внимание: это встроенная Discord Rich Presence в Psysonic. Если вы хотите использовать официальный плагин Discord Rich Presence для Navidrome, оставьте эту функцию выключенной и вместо этого включите «Показывать в „Сейчас играет"» ниже на этой странице.', discordRichPresenceNotice: 'Внимание: это встроенная Discord Rich Presence в Psysonic. Если вы хотите использовать официальный плагин Discord Rich Presence для Navidrome, оставьте эту функцию выключенной и вместо этого включите «Показывать в „Сейчас играет"» ниже на этой странице.',
discordCoverSource: 'Источник обложки', discordCoverTitle: 'Источник обложки',
discordCoverSourceDesc: 'Откуда загружать обложку альбома для профиля Discord.', discordCoverDesc: 'Откуда берётся обложка, показываемая в вашем профиле Discord.',
discordCoverNone: 'Нет (только иконка приложения)', discordCoverNone: 'Нет (только иконка приложения)',
discordCoverServer: 'Сервер (через информацию об альбоме)', discordCoverServer: 'Сервер (через информацию об альбоме)',
discordCoverApple: 'Apple Music', discordCoverApple: 'Apple Music',
@@ -321,7 +326,6 @@ export const settings = {
libraryIndexServerListTitle: 'Индексируемые серверы', libraryIndexServerListTitle: 'Индексируемые серверы',
libraryIndexAllExcluded: 'Все серверы исключены из синхронизации.', libraryIndexAllExcluded: 'Все серверы исключены из синхронизации.',
libraryIndexServerOffline: 'Сервер офлайн — синхронизация отложена', libraryIndexServerOffline: 'Сервер офлайн — синхронизация отложена',
libraryIndexServerDeferred: 'Отложено',
libraryIndexServerSyncing: 'Синхронизация…', libraryIndexServerSyncing: 'Синхронизация…',
libraryIndexFullResync: 'Полная пересинхронизация', libraryIndexFullResync: 'Полная пересинхронизация',
libraryIndexDeltaSync: 'Быстрая дельта', libraryIndexDeltaSync: 'Быстрая дельта',
+8 -4
View File
@@ -137,6 +137,11 @@ export const settings = {
eqAutoEqRateLimit: 'GitHub 请求限制 — 请一分钟后重试', eqAutoEqRateLimit: 'GitHub 请求限制 — 请一分钟后重试',
eqAutoEqFetchError: '无法获取 EQ 配置', eqAutoEqFetchError: '无法获取 EQ 配置',
behavior: '应用行为', behavior: '应用行为',
groupTray: '系统托盘',
groupLinuxRendering: 'Linux 渲染',
groupClock: '时钟',
groupDisplay: '显示',
groupWindow: '窗口',
cacheTitle: '最大存储大小', cacheTitle: '最大存储大小',
cacheDesc: '浏览器内封面和艺术家图片缓存(IndexedDB)。存满时,最旧的条目将自动移除。', cacheDesc: '浏览器内封面和艺术家图片缓存(IndexedDB)。存满时,最旧的条目将自动移除。',
cacheUsedImages: '图片:', cacheUsedImages: '图片:',
@@ -206,7 +211,7 @@ export const settings = {
audioOutputDeviceMacNotice: '在 macOS 上,出于技术原因,目前播放始终跟随系统输出设备。请通过 系统设置 → 声音 或菜单栏扬声器图标切换目标设备。背景:打开非默认音频流时 CoreAudio 会触发麦克风权限提示 —— 我们通过始终使用系统默认输出来避免此提示。', audioOutputDeviceMacNotice: '在 macOS 上,出于技术原因,目前播放始终跟随系统输出设备。请通过 系统设置 → 声音 或菜单栏扬声器图标切换目标设备。背景:打开非默认音频流时 CoreAudio 会触发麦克风权限提示 —— 我们通过始终使用系统默认输出来避免此提示。',
hiResTitle: '原生高清晰度播放', hiResTitle: '原生高清晰度播放',
hiResEnabled: '启用原生高清晰度播放', hiResEnabled: '启用原生高清晰度播放',
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率88.2 kHz+时启用。", hiResDesc: "以每个曲目的原始采样率播放,而不是将所有内容重采样到 44.1 kHz,并将输出设备切换为与文件匹配(88.2 kHz 及以上)。仅在硬件和网络可靠处理高采样率时启用。",
showArtistImages: '显示艺术家图片', showArtistImages: '显示艺术家图片',
showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。', showArtistImagesDesc: '在艺术家概览中加载并显示艺术家图片。默认关闭以减少大型音乐库的服务器磁盘I/O和网络负载。',
showOrbitTrigger: '在顶栏显示"Orbit"', showOrbitTrigger: '在顶栏显示"Orbit"',
@@ -235,8 +240,8 @@ export const settings = {
discordRichPresence: 'Discord Rich Presence', discordRichPresence: 'Discord Rich Presence',
discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。', discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。',
discordRichPresenceNotice: '注意:这是 Psysonic 内置的 Discord Rich Presence。如果你想使用官方的 Navidrome Discord Rich Presence 插件,请保持此功能关闭,并改为启用本页下方的"在实时窗口中显示"。', discordRichPresenceNotice: '注意:这是 Psysonic 内置的 Discord Rich Presence。如果你想使用官方的 Navidrome Discord Rich Presence 插件,请保持此功能关闭,并改为启用本页下方的"在实时窗口中显示"。',
discordCoverSource: '封面来源', discordCoverTitle: '封面来源',
discordCoverSourceDesc: '从何处获取 Discord 个人资料显示的专辑封面。', discordCoverDesc: '你的 Discord 资料显示的专辑封面来源。',
discordCoverNone: '无(仅显示应用图标)', discordCoverNone: '无(仅显示应用图标)',
discordCoverServer: '服务器(通过专辑信息)', discordCoverServer: '服务器(通过专辑信息)',
discordCoverApple: 'Apple Music', discordCoverApple: 'Apple Music',
@@ -299,7 +304,6 @@ export const settings = {
libraryIndexServerListTitle: '已索引的服务器', libraryIndexServerListTitle: '已索引的服务器',
libraryIndexAllExcluded: '所有服务器均已排除同步。请重新启用索引或添加服务器。', libraryIndexAllExcluded: '所有服务器均已排除同步。请重新启用索引或添加服务器。',
libraryIndexServerOffline: '服务器离线——同步已推迟', libraryIndexServerOffline: '服务器离线——同步已推迟',
libraryIndexServerDeferred: '已推迟',
libraryIndexServerSyncing: '同步中…', libraryIndexServerSyncing: '同步中…',
libraryIndexFullResync: '完全重新同步', libraryIndexFullResync: '完全重新同步',
libraryIndexDeltaSync: '增量同步', libraryIndexDeltaSync: '增量同步',
+26
View File
@@ -544,6 +544,18 @@ span.settings-server-use-active-slot {
padding: var(--space-3); 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 /* Boxed sub-section panel inside a settings card sets related controls
apart as a distinct block (e.g. Normalization / Track transitions / apart as a distinct block (e.g. Normalization / Track transitions /
Queue behaviour in the Playback card). */ Queue behaviour in the Playback card). */
@@ -559,6 +571,9 @@ span.settings-server-use-active-slot {
} }
.settings-group-title { .settings-group-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@@ -567,12 +582,23 @@ span.settings-server-use-active-slot {
margin-bottom: 6px; margin-bottom: 6px;
} }
.settings-group-title-icon {
display: inline-flex;
color: var(--accent);
}
.settings-group-desc { .settings-group-desc {
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 0.85rem; 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 { .settings-card.user-row {
transition: background 120ms ease, border-color 120ms ease; transition: background 120ms ease, border-color 120ms ease;
} }
+4
View File
@@ -236,6 +236,10 @@
color: var(--bg-app); color: var(--bg-app);
background: var(--accent); background: var(--accent);
} }
.queue-round-btn.active:hover:not(:disabled) {
color: var(--bg-app);
background: var(--accent);
}
.queue-current-track { .queue-current-track {
display: flex; display: flex;