mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user