refactor(settings): unify exclusive pickers on a shared segmented control (#1195)

Extract a reusable SettingsSegmented helper for the settings-segmented
pill picker. Track transitions adopts it unchanged (mode picker + AutoDJ
overlap cap). Lyrics scroll style and Queue display mode move from
stacks of mutually-exclusive toggles to the segmented picker, with the
active option's description shown below.
This commit is contained in:
Psychotoxical
2026-06-27 01:14:51 +02:00
committed by GitHub
parent 86ae462ad6
commit c59bf937e4
4 changed files with 119 additions and 70 deletions
+19 -16
View File
@@ -3,14 +3,24 @@ import { AudioLines, Music2 } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import { LyricsSourcesCustomizer } from './LyricsSourcesCustomizer';
import { SettingsSegmented, type SegmentedOption } from './SettingsSegmented';
import { SettingsSubCard, SettingsField } from './SettingsSubCard';
export function LyricsTab() {
const { t } = useTranslation();
const sidebarLyricsStyle = useAuthStore(s => s.sidebarLyricsStyle);
const setSidebarLyricsStyle = useAuthStore(s => s.setSidebarLyricsStyle);
const lyricsStyleOptions: SegmentedOption<'classic' | 'apple'>[] = [
{ id: 'classic', label: t('settings.sidebarLyricsStyleClassic') },
{ id: 'apple', label: t('settings.sidebarLyricsStyleApple') },
];
const lyricsStyleDescKey =
sidebarLyricsStyle === 'classic'
? 'settings.sidebarLyricsStyleClassicDesc'
: 'settings.sidebarLyricsStyleAppleDesc';
return (
<>
<SettingsSubSection
@@ -27,21 +37,14 @@ export function LyricsTab() {
icon={<AudioLines size={16} />}
>
<SettingsGroup>
{(['classic', 'apple'] as const).map((style, i) => {
const key = style === 'classic' ? 'Classic' : 'Apple';
const other = style === 'classic' ? 'apple' : 'classic';
return (
<div key={style}>
{i > 0 && <div className="settings-section-divider" />}
<SettingsToggle
label={t(`settings.sidebarLyricsStyle${key}`)}
desc={t(`settings.sidebarLyricsStyle${key}Desc`)}
checked={sidebarLyricsStyle === style}
onChange={c => setSidebarLyricsStyle(c ? style : other)}
/>
</div>
);
})}
<SettingsSegmented
options={lyricsStyleOptions}
value={sidebarLyricsStyle}
onChange={setSidebarLyricsStyle}
/>
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField desc={t(lyricsStyleDescKey)} />
</SettingsSubCard>
</SettingsGroup>
</SettingsSubSection>
</>
+25 -22
View File
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Disc3, LayoutGrid, ListOrdered, ListTodo, PanelLeft, RotateCcw, Users } from 'lucide-react';
import { useArtistLayoutStore } from '../../store/artistLayoutStore';
import { useAuthStore } from '../../store/authStore';
import type { QueueDisplayMode } from '../../store/authStoreTypes';
import { useHomeStore } from '../../store/homeStore';
import { usePlayerBarLayoutStore } from '../../store/playerBarLayoutStore';
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
@@ -10,6 +11,8 @@ import { useSidebarStore } from '../../store/sidebarStore';
import SettingsSubSection from '../SettingsSubSection';
import { SettingsGroup } from './SettingsGroup';
import { SettingsToggle } from './SettingsToggle';
import { SettingsSegmented, type SegmentedOption } from './SettingsSegmented';
import { SettingsSubCard, SettingsField } from './SettingsSubCard';
import { ArtistLayoutCustomizer } from './ArtistLayoutCustomizer';
import { HomeCustomizer } from './HomeCustomizer';
import { PlayerBarLayoutCustomizer } from './PlayerBarLayoutCustomizer';
@@ -24,6 +27,19 @@ export function PersonalisationTab() {
const preservePlayNextOrder = useAuthStore(s => s.preservePlayNextOrder);
const setPreservePlayNextOrder = useAuthStore(s => s.setPreservePlayNextOrder);
const advancedSettingsEnabled = useAuthStore(s => s.advancedSettingsEnabled);
const queueModeOptions: SegmentedOption<QueueDisplayMode>[] = [
{ id: 'queue', label: t('queue.title') },
{ id: 'playlist', label: t('queue.modePlaylist') },
{ id: 'timeline', label: t('queue.modeTimeline') },
];
const queueModeDescKey =
queueDisplayMode === 'queue'
? 'settings.queueModeQueueSub'
: queueDisplayMode === 'playlist'
? 'settings.queueModePlaylistSub'
: 'settings.queueModeTimelineSub';
return (
<>
<SettingsSubSection
@@ -95,30 +111,17 @@ export function PersonalisationTab() {
icon={<ListOrdered size={16} />}
>
<>
{/* Three mutually exclusive modes — a segmented picker enforces that
exactly one is active, instead of toggles that read as independent. */}
<SettingsGroup title={t('settings.queueModeTitle')}>
{/* Three mutually exclusive modes — exactly one is always active, so
turning one on turns the others off; the active one cannot be
switched off directly (ignore the uncheck). */}
<SettingsToggle
label={t('queue.title')}
desc={t('settings.queueModeQueueSub')}
checked={queueDisplayMode === 'queue'}
onChange={c => { if (c) setQueueDisplayMode('queue'); }}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('queue.modePlaylist')}
desc={t('settings.queueModePlaylistSub')}
checked={queueDisplayMode === 'playlist'}
onChange={c => { if (c) setQueueDisplayMode('playlist'); }}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('queue.modeTimeline')}
desc={t('settings.queueModeTimelineSub')}
checked={queueDisplayMode === 'timeline'}
onChange={c => { if (c) setQueueDisplayMode('timeline'); }}
<SettingsSegmented
options={queueModeOptions}
value={queueDisplayMode}
onChange={setQueueDisplayMode}
/>
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField desc={t(queueModeDescKey)} />
</SettingsSubCard>
</SettingsGroup>
<SettingsGroup title={t('settings.queueBehaviourTitle')}>
@@ -0,0 +1,55 @@
import type { CSSProperties } from 'react';
export interface SegmentedOption<T extends string> {
id: T;
label: string;
/** Disables this single option while leaving the rest selectable. */
disabled?: boolean;
}
interface Props<T extends string> {
options: SegmentedOption<T>[];
value: T;
onChange: (id: T) => void;
/** Disables the whole control (e.g. an Orbit guest mirroring the host). */
disabled?: boolean;
/** Extra class appended to the `settings-segmented` wrapper. */
className?: string;
/** Inline style on the wrapper (e.g. the dimmed host-controlled state). */
style?: CSSProperties;
}
/**
* Shared `settings-segmented` picker: a row of mutually-exclusive pill buttons
* where exactly one is active (`btn-primary`) and the rest are `btn-ghost`. The
* canonical replacement for stacks of mutually-exclusive toggles, which falsely
* read as "you can turn several on" — see the Track-transitions section for the
* reference look.
*
* Scope is the segmented control only; callers render any per-option detail
* (sliders, descriptions, sub-cards) below it themselves.
*/
export function SettingsSegmented<T extends string>({
options,
value,
onChange,
disabled,
className,
style,
}: Props<T>) {
return (
<div className={className ? `settings-segmented ${className}` : 'settings-segmented'} style={style}>
{options.map(opt => (
<button
key={opt.id}
type="button"
className={`btn ${value === opt.id ? 'btn-primary' : 'btn-ghost'}`}
disabled={disabled || opt.disabled}
onClick={() => onChange(opt.id)}
>
{opt.label}
</button>
))}
</div>
);
}
@@ -14,6 +14,7 @@ import {
import { SettingsGroup } from '../SettingsGroup';
import { SettingsToggle } from '../SettingsToggle';
import { SettingsSubCard, SettingsField, SettingsRow, SettingsValue } from '../SettingsSubCard';
import { SettingsSegmented, type SegmentedOption } from '../SettingsSegmented';
interface Props {
t: TFunction;
@@ -42,13 +43,18 @@ export function TrackTransitionsBlock({ t }: Props) {
s => s.role === 'guest' && (s.phase === 'active' || s.phase === 'joining'),
);
const transitions: { id: TransitionMode; label: string }[] = [
const transitions: SegmentedOption<TransitionMode>[] = [
{ id: 'none', label: t('settings.transitionOff') },
{ id: 'gapless', label: t('settings.gapless') },
{ id: 'crossfade', label: t('settings.crossfade') },
{ id: 'autodj', label: t('settings.autoDj') },
];
const overlapCapOptions: SegmentedOption<'auto' | 'limit'>[] = [
{ id: 'auto', label: t('settings.autodjOverlapCapAuto') },
{ id: 'limit', label: t('settings.autodjOverlapCapLimit') },
];
return (
<SettingsGroup>
{hostControlled && (
@@ -56,19 +62,13 @@ export function TrackTransitionsBlock({ t }: Props) {
{t('settings.transitionsHostControlled')}
</div>
)}
<div className="settings-segmented" style={hostControlled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
{transitions.map(item => (
<button
key={item.id}
type="button"
className={`btn ${mode === item.id ? 'btn-primary' : 'btn-ghost'}`}
disabled={hostControlled}
onClick={() => setTransitionMode(item.id)}
>
{item.label}
</button>
))}
</div>
<SettingsSegmented
options={transitions}
value={mode}
onChange={setTransitionMode}
disabled={hostControlled}
style={hostControlled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}
/>
{mode === 'crossfade' && (
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
@@ -96,24 +96,12 @@ export function TrackTransitionsBlock({ t }: Props) {
label={t('settings.autodjOverlapCapTitle')}
desc={t('settings.autodjOverlapCapDesc')}
>
<div className="settings-segmented">
<button
type="button"
className={`btn ${auth.autodjOverlapCapMode === 'auto' ? 'btn-primary' : 'btn-ghost'}`}
disabled={hostControlled}
onClick={() => auth.setAutodjOverlapCapMode('auto')}
>
{t('settings.autodjOverlapCapAuto')}
</button>
<button
type="button"
className={`btn ${auth.autodjOverlapCapMode === 'limit' ? 'btn-primary' : 'btn-ghost'}`}
disabled={hostControlled}
onClick={() => auth.setAutodjOverlapCapMode('limit')}
>
{t('settings.autodjOverlapCapLimit')}
</button>
</div>
<SettingsSegmented
options={overlapCapOptions}
value={auth.autodjOverlapCapMode}
onChange={auth.setAutodjOverlapCapMode}
disabled={hostControlled}
/>
{auth.autodjOverlapCapMode === 'limit' && (
<SettingsRow>
<input