-
- {t('settings.discordTemplateName')}
- auth.setDiscordTemplateName(e.target.value)}
- placeholder="{title}"
- />
-
-
- {t('settings.discordTemplateDetails')}
- auth.setDiscordTemplateDetails(e.target.value)}
- placeholder="{artist}"
- />
-
-
- {t('settings.discordTemplateState')}
- auth.setDiscordTemplateState(e.target.value)}
- placeholder="{title}"
- />
-
-
- {t('settings.discordTemplateLargeText')}
- auth.setDiscordTemplateLargeText(e.target.value)}
- placeholder="{album}"
- />
-
+
+
+ auth.setDiscordTemplateName(e.target.value)}
+ placeholder="{title}"
+ />
+
+
+ auth.setDiscordTemplateDetails(e.target.value)}
+ placeholder="{artist}"
+ />
+
+
+ auth.setDiscordTemplateState(e.target.value)}
+ placeholder="{title}"
+ />
+
+
+ auth.setDiscordTemplateLargeText(e.target.value)}
+ placeholder="{album}"
+ />
+
+
>
)}
@@ -166,31 +165,33 @@ export function IntegrationsTab() {
title={t('settings.externalArtworkByokTitle')}
desc={t('settings.externalArtworkByokDesc')}
>
-
-
- {t('settings.randomMixBlacklistDesc')}
-
-
- {t('settings.randomMixBlacklistTitle')}
-
- {auth.customGenreBlacklist.length === 0 ? (
- {t('settings.randomMixBlacklistEmpty')}
- ) : (
- auth.customGenreBlacklist.map(genre => (
-
- {genre}
- auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
- aria-label={`Remove ${genre}`}
- >×
-
- ))
- )}
-
-
-
- setNewGenre(e.target.value)}
- onKeyDown={e => {
- if (e.key === 'Enter' && newGenre.trim()) {
- const trimmed = newGenre.trim();
- if (!auth.customGenreBlacklist.includes(trimmed)) {
- auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
- }
- setNewGenre('');
- }
- }}
- placeholder={t('settings.randomMixBlacklistPlaceholder')}
- style={{ fontSize: 13 }}
- />
- {
- const trimmed = newGenre.trim();
- if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
- auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
- }
- setNewGenre('');
- }}
- disabled={!newGenre.trim()}
- >
- {t('settings.randomMixBlacklistAdd')}
-
-
-
- {t('settings.randomMixHardcodedTitle')}
-
- {AUDIOBOOK_GENRES_DISPLAY.map(genre => (
-
- {genre}
-
- ))}
-
+
+
+
+
+ {auth.customGenreBlacklist.length === 0 ? (
+ {t('settings.randomMixBlacklistEmpty')}
+ ) : (
+ auth.customGenreBlacklist.map(genre => (
+
+ {genre}
+ auth.setCustomGenreBlacklist(auth.customGenreBlacklist.filter(g => g !== genre))}
+ aria-label={`Remove ${genre}`}
+ >×
+
+ ))
+ )}
+
+
+ setNewGenre(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter' && newGenre.trim()) {
+ const trimmed = newGenre.trim();
+ if (!auth.customGenreBlacklist.includes(trimmed)) {
+ auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
+ }
+ setNewGenre('');
+ }
+ }}
+ placeholder={t('settings.randomMixBlacklistPlaceholder')}
+ style={{ fontSize: 13 }}
+ />
+ {
+ const trimmed = newGenre.trim();
+ if (trimmed && !auth.customGenreBlacklist.includes(trimmed)) {
+ auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]);
+ }
+ setNewGenre('');
+ }}
+ disabled={!newGenre.trim()}
+ >
+ {t('settings.randomMixBlacklistAdd')}
+
+
+
+
+
+ {AUDIOBOOK_GENRES_DISPLAY.map(genre => (
+
+ {genre}
+
+ ))}
+
+
+
@@ -156,8 +156,7 @@ export function LibraryTab() {
onChange={auth.setMixMinRatingFilterEnabled}
/>
{auth.mixMinRatingFilterEnabled && (
- <>
- `s — see `NormalizationBlock` for usage.
+ */
+
+/** Bordered, accent-tinted panel that groups detail controls inside a section. */
+export function SettingsSubCard({
+ children,
+ style,
+ className,
+}: {
+ children: React.ReactNode;
+ style?: React.CSSProperties;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * One labelled group inside a {@link SettingsSubCard}: an optional title and
+ * description, the controls, and an optional grey note line below them.
+ *
+ * `row` lays the label out beside the controls (for a slider/value pair, like
+ * the Normalization sliders); the default stacks label → description →
+ * controls (for a picker, like the Hi-Res blend-rate buttons).
+ */
+export function SettingsField({
+ label,
+ desc,
+ note,
+ row = false,
+ children,
+}: {
+ label?: React.ReactNode;
+ desc?: React.ReactNode;
+ note?: React.ReactNode;
+ row?: boolean;
+ children?: React.ReactNode;
+}) {
+ return (
+
+ {row ? (
+ <>
+
+ {label != null && {label} }
+ {children}
+
+ {desc != null &&
{desc}
}
+ >
+ ) : (
+ <>
+ {label != null && (
+
+ {label}
+
+ )}
+ {desc != null &&
{desc}
}
+ {children}
+ >
+ )}
+ {note != null && (
+
+ {note}
+
+ )}
+
+ );
+}
+
+/**
+ * A horizontal control row (label and/or control + value side by side), for a
+ * standalone slider inside a {@link SettingsSubCard} or a nested slider/value
+ * pair inside a stacked {@link SettingsField}. For a labelled field whose
+ * description sits below the row, prefer `
`.
+ */
+export function SettingsRow({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+/** Right-aligned, tabular value readout for a slider row. */
+export function SettingsValue({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
+
+/**
+ * Accent callout (border-left + tinted background) for an important caveat
+ * inside a {@link SettingsSubCard} — e.g. the Normalization first-play note.
+ * For a plain grey note line, use the `note` prop on {@link SettingsField}.
+ */
+export function SettingsCallout({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
diff --git a/src/components/settings/StorageTab.tsx b/src/components/settings/StorageTab.tsx
index 86a0da1d..924697b5 100644
--- a/src/components/settings/StorageTab.tsx
+++ b/src/components/settings/StorageTab.tsx
@@ -10,6 +10,7 @@ import { formatBytes, snapHotCacheMb } from '../../utils/format/formatBytes';
import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
+import { SettingsSubCard, SettingsField, SettingsValue } from './SettingsSubCard';
import CoverCacheStrategySection from './CoverCacheStrategySection';
export function StorageTab() {
@@ -77,33 +78,32 @@ export function StorageTab() {
>
@@ -135,42 +135,35 @@ export function StorageTab() {
/>
{auth.hotCacheEnabled && (
-
-
-
-
{t('settings.cacheUsedHot')}
- {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
+
+
+
+
+ {t('settings.cacheUsedHot')}
+ {hotCacheBytes !== null ? formatBytes(hotCacheBytes) : '…'}
+
+
+ {t('settings.hotCacheTrackCount')}
+ {hotCacheTrackCount}
+
-
- {t('settings.hotCacheTrackCount')}
- {hotCacheTrackCount}
-
-
-
-
-
{t('settings.hotCacheMaxMb')}
-
- auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-max-mb-slider" />
- {snapHotCacheMb(auth.hotCacheMaxMb)} MB
-
-
-
-
{t('settings.hotCacheDebounce')}
-
- auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} style={{ flex: 1, minWidth: 80, maxWidth: 200 }} id="hot-cache-debounce-slider" />
-
- {Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
- ? t('settings.hotCacheDebounceImmediate')
- : t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
-
-
-
-
-
+
+
+ auth.setHotCacheMaxMb(parseInt(e.target.value, 10))} id="hot-cache-max-mb-slider" />
+ {snapHotCacheMb(auth.hotCacheMaxMb)} MB
+
+
+ auth.setHotCacheDebounceSec(parseInt(e.target.value, 10))} id="hot-cache-debounce-slider" />
+
+ {Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) === 0
+ ? t('settings.hotCacheDebounceImmediate')
+ : t('settings.hotCacheDebounceSeconds', { n: Math.min(600, Math.max(0, auth.hotCacheDebounceSec)) })}
+
+
{
await clearHotCacheDisk(mediaDir);
refreshHotCacheSize();
@@ -178,7 +171,7 @@ export function StorageTab() {
>
{t('settings.hotCacheClearBtn')}
-
+
)}
@@ -190,29 +183,33 @@ export function StorageTab() {
>
diff --git a/src/components/settings/SystemTab.tsx b/src/components/settings/SystemTab.tsx
index 083624a6..15e9e5b2 100644
--- a/src/components/settings/SystemTab.tsx
+++ b/src/components/settings/SystemTab.tsx
@@ -17,6 +17,7 @@ import LicensesPanel from '../LicensesPanel';
import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
+import { SettingsSubCard, SettingsField } from './SettingsSubCard';
import { BackupSection } from './BackupSection';
import { CONTRIBUTORS, MAINTAINERS } from '../../config/settingsCredits';
@@ -58,25 +59,27 @@ export function SystemTab() {
>
-
- i18n.changeLanguage(v)}
- options={[
- { value: 'en', label: t('settings.languageEn') },
- { value: 'de', label: t('settings.languageDe') },
- { value: 'es', label: t('settings.languageEs') },
- { value: 'fr', label: t('settings.languageFr') },
- { value: 'nl', label: t('settings.languageNl') },
- { value: 'nb', label: t('settings.languageNb') },
- { value: 'ru', label: t('settings.languageRu') },
- { value: 'zh', label: t('settings.languageZh') },
- { value: 'ro', label: t('settings.languageRo') },
- { value: 'ja', label: t('settings.languageJa') },
- { value: 'hu', label: t('settings.languageHu') },
- ]}
- />
-
+
+
+ i18n.changeLanguage(v)}
+ options={[
+ { value: 'en', label: t('settings.languageEn') },
+ { value: 'de', label: t('settings.languageDe') },
+ { value: 'es', label: t('settings.languageEs') },
+ { value: 'fr', label: t('settings.languageFr') },
+ { value: 'nl', label: t('settings.languageNl') },
+ { value: 'nb', label: t('settings.languageNb') },
+ { value: 'ru', label: t('settings.languageRu') },
+ { value: 'zh', label: t('settings.languageZh') },
+ { value: 'ro', label: t('settings.languageRo') },
+ { value: 'ja', label: t('settings.languageJa') },
+ { value: 'hu', label: t('settings.languageHu') },
+ ]}
+ />
+
+
@@ -119,13 +122,11 @@ export function SystemTab() {
onChange={auth.setLinuxWebkitInputForceRepaint}
/>
{waylandTextRenderAvailable && (
- <>
-
-
-
{t('settings.linuxWaylandTextRender')}
-
- {t('settings.linuxWaylandTextRenderDesc')}
-
+
+
auth.setLinuxWaylandTextRenderProfile(v as LinuxWaylandTextRenderProfile)}
@@ -136,30 +137,28 @@ export function SystemTab() {
{ value: 'minimal', label: t('settings.linuxWaylandTextRenderMinimal') },
]}
/>
-
- >
+
+
)}
)}
-
-
-
{t('settings.clockFormat')}
-
{t('settings.clockFormatDesc')}
-
-
- auth.setClockFormat(v as ClockFormat)}
- options={[
- { value: 'auto', label: t('settings.clockFormatAuto') },
- { value: '24h', label: t('settings.clockFormatTwentyFour') },
- { value: '12h', label: t('settings.clockFormatTwelve') },
- ]}
- />
-
-
+
+
+
+ auth.setClockFormat(v as ClockFormat)}
+ options={[
+ { value: 'auto', label: t('settings.clockFormatAuto') },
+ { value: '24h', label: t('settings.clockFormatTwentyFour') },
+ { value: '12h', label: t('settings.clockFormatTwelve') },
+ ]}
+ />
+
+
+
@@ -177,26 +176,27 @@ export function SystemTab() {
>
-
-
-
{t('settings.replayGainMode')}
-
- auth.setReplayGainMode('auto')}
- >
- {t('settings.replayGainAuto')}
-
- auth.setReplayGainMode('track')}
- >
- {t('settings.replayGainTrack')}
-
- auth.setReplayGainMode('album')}
- >
- {t('settings.replayGainAlbum')}
-
-
-
- {auth.replayGainMode === 'auto' && (
-
{t('settings.replayGainAutoDesc')}
- )}
-
-
-
- {t('settings.replayGainPreGain')}
- auth.setReplayGainPreGainDb(Number(e.target.value))}
- />
-
- {auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB
-
-
-
{t('settings.replayGainPreGainDesc')}
-
-
-
- {t('settings.replayGainFallback')}
- auth.setReplayGainFallbackDb(Number(e.target.value))}
- />
-
- {auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB
-
-
-
{t('settings.replayGainFallbackDesc')}
-
-
- )}
- {auth.normalizationEngine === 'loudness' && (
-
-
-
- {t('settings.loudnessTargetLufs')}
-
-
-
{t('settings.loudnessTargetLufsDesc')}
-
-
-
-
{t('settings.loudnessPreAnalysisAttenuation')}
-
auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))}
- />
-
- {preAnalysisEffectiveDb} dB
-
+
+
+
auth.resetLoudnessPreAnalysisAttenuationDbDefault()}
- data-tooltip={t('settings.loudnessPreAnalysisAttenuationReset')}
- aria-label={t('settings.loudnessPreAnalysisAttenuationReset')}
+ className={`btn ${auth.replayGainMode === 'auto' ? 'btn-primary' : 'btn-ghost'}`}
+ style={{ fontSize: 12, padding: '4px 14px' }}
+ onClick={() => auth.setReplayGainMode('auto')}
>
-
+ {t('settings.replayGainAuto')}
+
+ auth.setReplayGainMode('track')}
+ >
+ {t('settings.replayGainTrack')}
+
+ auth.setReplayGainMode('album')}
+ >
+ {t('settings.replayGainAlbum')}
-
- {t('settings.loudnessPreAnalysisAttenuationDesc')}{' '}
- {t('settings.loudnessPreAnalysisAttenuationRef', {
- ref: auth.loudnessPreAnalysisAttenuationDb,
- eff: preAnalysisEffectiveDb,
- tgt: auth.loudnessTargetLufs,
- })}
-
-
-
{t('settings.loudnessFirstPlayNote')}
-
+
+
+ auth.setReplayGainPreGainDb(Number(e.target.value))}
+ />
+
+ {auth.replayGainPreGainDb > 0 ? `+${auth.replayGainPreGainDb}` : auth.replayGainPreGainDb} dB
+
+
+
+ auth.setReplayGainFallbackDb(Number(e.target.value))}
+ />
+
+ {auth.replayGainFallbackDb > 0 ? `+${auth.replayGainFallbackDb}` : auth.replayGainFallbackDb} dB
+
+
+
+ )}
+ {auth.normalizationEngine === 'loudness' && (
+
+
+
+
+
+ {t('settings.loudnessPreAnalysisAttenuationDesc')}{' '}
+ {t('settings.loudnessPreAnalysisAttenuationRef', {
+ ref: auth.loudnessPreAnalysisAttenuationDb,
+ eff: preAnalysisEffectiveDb,
+ tgt: auth.loudnessTargetLufs,
+ })}
+ >
+ }
+ row
+ >
+ auth.setLoudnessPreAnalysisAttenuationDb(Number(e.target.value))}
+ />
+ {preAnalysisEffectiveDb} dB
+ auth.resetLoudnessPreAnalysisAttenuationDbDefault()}
+ data-tooltip={t('settings.loudnessPreAnalysisAttenuationReset')}
+ aria-label={t('settings.loudnessPreAnalysisAttenuationReset')}
+ >
+
+
+
+ {t('settings.loudnessFirstPlayNote')}
+
)}
);
diff --git a/src/components/settings/audio/PlaybackRateBlock.tsx b/src/components/settings/audio/PlaybackRateBlock.tsx
index 20b607e9..242be702 100644
--- a/src/components/settings/audio/PlaybackRateBlock.tsx
+++ b/src/components/settings/audio/PlaybackRateBlock.tsx
@@ -23,6 +23,7 @@ import { useOrbitStore } from '../../../store/orbitStore';
import { useAuthStore } from '../../../store/authStore';
import { isOrbitPlaybackSyncActive } from '../../../utils/orbit';
import { SettingsToggle } from '../SettingsToggle';
+import { SettingsSubCard } from '../SettingsSubCard';
interface Props {
t: TFunction;
@@ -118,10 +119,9 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
/>
)}
- {(!showEnable || enabled) && (
- <>
- {showEnable &&
}
-
+ {(!showEnable || enabled) && (() => {
+ const body = (
+ <>
{!compact && (
{t('settings.playbackRateStrategy')}
@@ -281,8 +281,12 @@ export function PlaybackRateControls({ t, showEnable = true }: Props) {
{t('settings.playbackRateNeutral')}
)}
- >
- )}
+ >
+ );
+ return compact
+ ? body
+ : {body} ;
+ })()}
);
}
diff --git a/src/components/settings/audio/TrackPreviewsSection.tsx b/src/components/settings/audio/TrackPreviewsSection.tsx
index bf836a4c..19f42303 100644
--- a/src/components/settings/audio/TrackPreviewsSection.tsx
+++ b/src/components/settings/audio/TrackPreviewsSection.tsx
@@ -7,6 +7,7 @@ import type { TrackPreviewLocation } from '../../../store/authStoreTypes';
import SettingsSubSection from '../../SettingsSubSection';
import { SettingsGroup } from '../SettingsGroup';
import { SettingsToggle } from '../SettingsToggle';
+import { SettingsSubCard, SettingsField, SettingsValue } from '../SettingsSubCard';
interface Props {
t: TFunction;
@@ -37,20 +38,12 @@ export function TrackPreviewsSection({ t }: Props) {
/>
{auth.trackPreviewsEnabled && (
- <>
-
-
-
- {t('settings.trackPreviewLocationsTitle')}
-
-
- {t('settings.trackPreviewLocationsDesc')}
-
-
+
+
+
{TRACK_PREVIEW_LOCATIONS.map((loc: TrackPreviewLocation) => (
@@ -64,58 +57,44 @@ export function TrackPreviewsSection({ t }: Props) {
))}
-
+
-
-
-
- {t('settings.trackPreviewStart')}
-
-
- {t('settings.trackPreviewStartDesc')}
-
-
- auth.setTrackPreviewStartRatio(parseFloat(e.target.value))}
- style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
- aria-label={t('settings.trackPreviewStart')}
- />
-
- {Math.round(auth.trackPreviewStartRatio * 100)}%
-
-
-
+
+ auth.setTrackPreviewStartRatio(parseFloat(e.target.value))}
+ aria-label={t('settings.trackPreviewStart')}
+ />
+ {Math.round(auth.trackPreviewStartRatio * 100)}%
+
-
-
-
- {t('settings.trackPreviewDuration')}
-
-
- {t('settings.trackPreviewDurationDesc')}
-
-
- auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))}
- style={{ flex: 1, minWidth: 80, maxWidth: 240 }}
- aria-label={t('settings.trackPreviewDuration')}
- />
-
- {t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })}
-
-
-
- >
+
+ auth.setTrackPreviewDurationSec(parseInt(e.target.value, 10))}
+ aria-label={t('settings.trackPreviewDuration')}
+ />
+
+ {t('settings.trackPreviewDurationSecs', { n: auth.trackPreviewDurationSec })}
+
+
+
)}
diff --git a/src/components/settings/audio/TrackTransitionsBlock.tsx b/src/components/settings/audio/TrackTransitionsBlock.tsx
index 1e3425c5..e30ec2b9 100644
--- a/src/components/settings/audio/TrackTransitionsBlock.tsx
+++ b/src/components/settings/audio/TrackTransitionsBlock.tsx
@@ -13,6 +13,7 @@ import {
} from '../../../utils/playback/playbackTransition';
import { SettingsGroup } from '../SettingsGroup';
import { SettingsToggle } from '../SettingsToggle';
+import { SettingsSubCard, SettingsField, SettingsRow, SettingsValue } from '../SettingsSubCard';
interface Props {
t: TFunction;
@@ -70,8 +71,8 @@ export function TrackTransitionsBlock({ t }: Props) {
{mode === 'crossfade' && (
-
-
+
+
auth.setCrossfadeSecs(parseFloat(e.target.value))}
id="crossfade-secs-slider"
/>
-
+
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
-
-
-
+
+
+
)}
{mode === 'autodj' && (
-
-
{t('settings.autoDjDesc')}
-
-
- {t('settings.autodjOverlapCapTitle')}
-
-
{t('settings.autodjOverlapCapDesc')}
+
+
+
{auth.autodjOverlapCapMode === 'limit' && (
-
+
auth.setAutodjOverlapCapSec(parseInt(e.target.value, 10))}
id="autodj-overlap-cap-slider"
/>
-
+
{t('settings.autodjOverlapCapSecs', { n: auth.autodjOverlapCapSec })}
-
-
+
+
)}
-
+
-
+
)}
);