mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
327 lines
14 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|