Files
Psychotoxical-psysonic/src/features/settings/components/AppearanceTab.tsx
T

327 lines
14 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { isTilingWmCmd } from '@/lib/api/platformShell';
import { LayoutGrid, Maximize2, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import {
LIBRARY_GRID_MAX_COLUMNS_MAX,
LIBRARY_GRID_MAX_COLUMNS_MIN,
} from '@/store/authStoreDefaults';
import type { SeekbarStyle, WindowButtonStyle } from '@/store/authStoreTypes';
import { useFontStore, FontId } from '@/store/fontStore';
import { useThemeStore } from '@/store/themeStore';
import { IS_LINUX, IS_WINDOWS } from '@/lib/util/platform';
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
import { SettingsSubCard, SettingsField, SettingsValue } from '@/features/settings/components/SettingsSubCard';
import { SettingsSegmented, type SegmentedOption } from '@/features/settings/components/SettingsSegmented';
import { SeekbarPreview } from '@/features/waveform';
import WindowButtonPreview from '@/features/settings/components/WindowButtonPreview';
export function AppearanceTab() {
const { t } = useTranslation();
const auth = useAuthStore();
const theme = useThemeStore();
const fontStore = useFontStore();
const [isTilingWm, setIsTilingWm] = useState(false);
useEffect(() => {
if (!IS_LINUX) return;
isTilingWmCmd().then(setIsTilingWm).catch(() => {});
}, []);
return (
<>
<SettingsSubSection
title={t('settings.libraryGridMaxColumnsTitle')}
icon={<LayoutGrid size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<div className="settings-hint settings-hint-info" style={{ marginBottom: '0.75rem' }}>
{t('settings.libraryGridMaxColumnsPerfHint')}
</div>
<SettingsSubCard>
<SettingsField
label={t('settings.libraryGridMaxColumnsRangeLabel', {
min: LIBRARY_GRID_MAX_COLUMNS_MIN,
max: LIBRARY_GRID_MAX_COLUMNS_MAX,
})}
desc={t('settings.libraryGridMaxColumnsDesc')}
row
>
<input
id="library-grid-max-cols"
type="range"
min={LIBRARY_GRID_MAX_COLUMNS_MIN}
max={LIBRARY_GRID_MAX_COLUMNS_MAX}
step={1}
value={auth.libraryGridMaxColumns}
onChange={e => auth.setLibraryGridMaxColumns(Number(e.target.value))}
aria-valuemin={LIBRARY_GRID_MAX_COLUMNS_MIN}
aria-valuemax={LIBRARY_GRID_MAX_COLUMNS_MAX}
aria-valuenow={auth.libraryGridMaxColumns}
/>
<SettingsValue>{auth.libraryGridMaxColumns}</SettingsValue>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.visualOptionsTitle')}
icon={<Palette size={16} />}
>
<div className="settings-card">
<SettingsGroup title={t('settings.groupDisplay')}>
<SettingsToggle
label={t('settings.coverArtBackground')}
desc={t('settings.coverArtBackgroundSub')}
checked={theme.enableCoverArtBackground}
onChange={theme.setEnableCoverArtBackground}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.playlistCoverPhoto')}
desc={t('settings.playlistCoverPhotoSub')}
checked={theme.enablePlaylistCoverPhoto}
onChange={theme.setEnablePlaylistCoverPhoto}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.showBitrate')}
desc={t('settings.showBitrateSub')}
checked={theme.showBitrate}
onChange={theme.setShowBitrate}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.floatingPlayerBar')}
desc={t('settings.floatingPlayerBarSub')}
checked={theme.floatingPlayerBar}
onChange={theme.setFloatingPlayerBar}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.squareCorners')}
desc={t('settings.squareCornersSub')}
checked={theme.squareCorners}
onChange={theme.setSquareCorners}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.showArtistImages')}
desc={t('settings.showArtistImagesDesc')}
checked={auth.showArtistImages}
onChange={auth.setShowArtistImages}
/>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.showOrbitTrigger')}
desc={t('settings.showOrbitTriggerDesc')}
checked={auth.showOrbitTrigger}
onChange={auth.setShowOrbitTrigger}
/>
{!IS_WINDOWS && (
<>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.preloadMiniPlayer')}
desc={t('settings.preloadMiniPlayerDesc')}
checked={auth.preloadMiniPlayer}
onChange={auth.setPreloadMiniPlayer}
/>
</>
)}
</SettingsGroup>
{IS_LINUX && !isTilingWm && (
<SettingsGroup title={t('settings.groupWindow')}>
<SettingsToggle
label={t('settings.useCustomTitlebar')}
desc={t('settings.useCustomTitlebarDesc')}
checked={auth.useCustomTitlebar}
onChange={auth.setUseCustomTitlebar}
/>
{auth.useCustomTitlebar && (
<>
<SettingsSubCard style={{ marginTop: '0.85rem' }}>
<SettingsField
label={t('settings.windowButtonStyle')}
desc={t('settings.windowButtonStyleDesc')}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{(['dots', 'dotsGlyph', 'flat', 'pill', 'outline', 'glyph'] as WindowButtonStyle[]).map(style => (
<WindowButtonPreview
key={style}
style={style}
label={t(`settings.windowButtons${style.charAt(0).toUpperCase() + style.slice(1)}`)}
selected={auth.windowButtonStyle === style}
onClick={() => auth.setWindowButtonStyle(style)}
/>
))}
</div>
</SettingsField>
</SettingsSubCard>
<div className="settings-section-divider" />
<SettingsToggle
label={t('settings.showMinimizeButton')}
desc={t('settings.showMinimizeButtonDesc')}
checked={auth.showMinimizeButton}
onChange={auth.setShowMinimizeButton}
/>
</>
)}
</SettingsGroup>
)}
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.uiScaleTitle')}
icon={<ZoomIn size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<SettingsField label={t('settings.uiScaleLabel')}>
{(() => {
const presets = [80, 90, 100, 110, 125, 150];
const currentPct = Math.round(fontStore.uiScale * 100);
// Snap a legacy off-preset value to the closest preset so one
// button is always marked active.
const activePct = presets.includes(currentPct)
? currentPct
: presets.reduce(
(best, p) => (Math.abs(p - currentPct) < Math.abs(best - currentPct) ? p : best),
presets[0],
);
const options: SegmentedOption<string>[] = presets.map(p => ({
id: String(p),
label: `${p}%`,
}));
return (
<SettingsSegmented
options={options}
value={String(activePct)}
onChange={id => fontStore.setUiScale(parseInt(id, 10) / 100)}
/>
);
})()}
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.font')}
icon={<Type size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{(
[
// Accessibility-first: OpenDyslexic at the top so dyslexic
// readers don't have to scroll past 14 sans-serifs to find it.
{ id: 'opendyslexic', label: 'OpenDyslexic', stack: "'OpenDyslexic', sans-serif", hint: t('settings.fontHintOpenDyslexic') },
{ id: 'inter', label: 'Inter', stack: "'Inter Variable', sans-serif" },
{ id: 'outfit', label: 'Outfit', stack: "'Outfit Variable', sans-serif" },
{ id: 'dm-sans', label: 'DM Sans', stack: "'DM Sans Variable', sans-serif" },
{ id: 'nunito', label: 'Nunito', stack: "'Nunito Variable', sans-serif" },
{ id: 'rubik', label: 'Rubik', stack: "'Rubik Variable', sans-serif" },
{ id: 'space-grotesk', label: 'Space Grotesk', stack: "'Space Grotesk Variable', sans-serif" },
{ id: 'figtree', label: 'Figtree', stack: "'Figtree Variable', sans-serif" },
{ id: 'manrope', label: 'Manrope', stack: "'Manrope Variable', sans-serif" },
{ id: 'plus-jakarta-sans', label: 'Plus Jakarta Sans', stack: "'Plus Jakarta Sans Variable', sans-serif" },
{ id: 'lexend', label: 'Lexend', stack: "'Lexend Variable', sans-serif" },
{ id: 'geist', label: 'Geist', stack: "'Geist Variable', sans-serif" },
{ id: 'jetbrains-mono', label: 'JetBrains Mono', stack: "'JetBrains Mono Variable', monospace" },
{ id: 'golos-text', label: 'Golos Text', stack: "'Golos Text Variable', sans-serif" },
{ id: 'unbounded', label: 'Unbounded', stack: "'Unbounded Variable', sans-serif" },
] as { id: FontId; label: string; stack: string; hint?: string }[]
).map(f => (
<button
key={f.id}
className={`btn ${fontStore.font === f.id ? 'btn-primary' : 'btn-ghost'}`}
style={{
justifyContent: 'flex-start',
fontFamily: f.stack,
...(f.hint ? { flexDirection: 'column', alignItems: 'flex-start', gap: '2px', paddingTop: '8px', paddingBottom: '8px' } : null),
}}
onClick={() => fontStore.setFont(f.id)}
>
<span>{f.label}</span>
{f.hint && (
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'var(--font-sans)' }}>
{f.hint}
</span>
)}
</button>
))}
</div>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.seekbarStyle')}
icon={<Sliders size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<SettingsField desc={t('settings.seekbarStyleDesc')}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{(['truewave', 'pseudowave', 'linedot', 'bar', 'thick', 'segmented', 'neon', 'pulsewave', 'particletrail', 'liquidfill', 'retrotape'] as SeekbarStyle[]).map(style => (
<SeekbarPreview
key={style}
style={style}
label={t(`settings.seekbar${style.charAt(0).toUpperCase() + style.slice(1)}`)}
selected={auth.seekbarStyle === style}
onClick={() => auth.setSeekbarStyle(style)}
/>
))}
</div>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.buttonSizeTitle')}
icon={<Maximize2 size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<SettingsField
label={t('settings.buttonSizeLabel')}
desc={t('settings.buttonSizeDesc')}
>
<div style={{ display: 'flex', gap: 8 }}>
{(['large', 'small'] as const).map(size => (
<button
key={size}
className={`btn ${theme.buttonSize === size ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => theme.setButtonSize(size)}
>
{t(`settings.buttonSize_${size}`)}
</button>
))}
</div>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
</>
);
}