redesign lyrics settings form

This commit is contained in:
jeffvli
2026-07-29 22:04:47 -07:00
parent 75c43d6724
commit 81f7b42409
5 changed files with 296 additions and 233 deletions
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
import { languages } from '/@/i18n/i18n';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
ListConfigBooleanControl,
ListConfigTable,
} from '/@/renderer/features/shared/components/list-config-menu';
import {
useLyricsDisplaySettings,
useLyricsSettings,
@@ -19,9 +19,7 @@ import { SegmentedControl } from '/@/shared/components/segmented-control/segment
import { Select } from '/@/shared/components/select/select';
import { Slider } from '/@/shared/components/slider/slider';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text';
import { LyricSource } from '/@/shared/types/domain-types';
const localSettings = isElectron() ? window.api.localSettings : null;
@@ -58,131 +56,169 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
});
};
const displayOptions: SettingOption[] = [
const displayOptions = [
{
control: (
<NumberInput
onBlur={(e) => {
const value = Number(e.currentTarget.value);
component: (
<Slider
defaultValue={displaySettings.fontSize}
label={(value) => `${value}px`}
marks={[
{ label: '8', value: 8 },
{ label: '24', value: 24 },
{ label: '48', value: 48 },
{ label: '72', value: 72 },
]}
max={72}
min={8}
onChangeEnd={(value) => {
updateDisplaySetting({ fontSize: value });
}}
rightSection={
<Text pr="md" size="sm">
px
</Text>
}
step={1}
value={displaySettings.fontSize}
width={100}
w="75%"
/>
),
description: '',
title: t(
description: t('page.fullscreenPlayer.config.lyricSizeSynchronized', {
context: 'description',
}),
id: 'fontSize',
label: t(
`${t('page.fullscreenPlayer.config.lyricSize')} (${t('page.fullscreenPlayer.config.synchronized')})`,
),
},
{
control: (
<NumberInput
onBlur={(e) => {
const value = Number(e.currentTarget.value);
component: (
<Slider
defaultValue={displaySettings.fontSizeUnsync}
label={(value) => `${value}px`}
marks={[
{ label: '8', value: 8 },
{ label: '24', value: 24 },
{ label: '48', value: 48 },
{ label: '72', value: 72 },
]}
max={72}
min={8}
onChangeEnd={(value) => {
updateDisplaySetting({ fontSizeUnsync: value });
}}
rightSection={
<Text pr="md" size="sm">
px
</Text>
}
step={1}
value={displaySettings.fontSizeUnsync}
width={100}
w="75%"
/>
),
description: '',
title: t(
description: t('page.fullscreenPlayer.config.lyricSizeUnsynchronized', {
context: 'description',
}),
id: 'fontSizeUnsync',
label: t(
`${t('page.fullscreenPlayer.config.lyricSize')} (${t('page.fullscreenPlayer.config.unsynchronized')})`,
),
},
{
control: (
<NumberInput
onBlur={(e) => {
const value = Number(e.currentTarget.value);
component: (
<Slider
defaultValue={displaySettings.gap}
label={(value) => `${value}px`}
marks={[
{ label: '0', value: 0 },
{ label: '25', value: 25 },
{ label: '50', value: 50 },
]}
max={50}
min={0}
onChangeEnd={(value) => {
updateDisplaySetting({ gap: value });
}}
rightSection={
<Text pr="md" size="sm">
px
</Text>
}
step={1}
value={displaySettings.gap}
width={100}
w="75%"
/>
),
description: '',
title: t(
description: t('page.fullscreenPlayer.config.lyricGapSynchronized', {
context: 'description',
}),
id: 'gap',
label: t(
`${t('page.fullscreenPlayer.config.lyricGap')} (${t('page.fullscreenPlayer.config.synchronized')})`,
),
},
{
control: (
<NumberInput
onBlur={(e) => {
const value = Number(e.currentTarget.value);
component: (
<Slider
defaultValue={displaySettings.gapUnsync}
label={(value) => `${value}px`}
marks={[
{ label: '0', value: 0 },
{ label: '25', value: 25 },
{ label: '50', value: 50 },
]}
max={50}
min={0}
onChangeEnd={(value) => {
updateDisplaySetting({ gapUnsync: value });
}}
rightSection={
<Text pr="md" size="sm">
px
</Text>
}
step={1}
value={displaySettings.gapUnsync}
width={100}
w="75%"
/>
),
description: '',
title: t(
description: t('page.fullscreenPlayer.config.lyricGapUnsynchronized', {
context: 'description',
}),
id: 'gapUnsync',
label: t(
`${t('page.fullscreenPlayer.config.lyricGap')} (${t('page.fullscreenPlayer.config.unsynchronized')})`,
),
},
{
control: (
component: (
<Slider
defaultValue={displaySettings.paddingLeft ?? 0}
label={(value) => `${value}%`}
marks={[
{ label: '0', value: 0 },
{ label: '25', value: 25 },
{ label: '50', value: 50 },
]}
max={50}
min={0}
onChangeEnd={(value) => {
updateDisplaySetting({ paddingLeft: value });
}}
step={1}
w={100}
w="100%"
/>
),
description: '',
title: t('page.fullscreenPlayer.config.lyricPaddingLeft'),
description: t('page.fullscreenPlayer.config.lyricPaddingLeft', {
context: 'description',
}),
id: 'paddingLeft',
label: t('page.fullscreenPlayer.config.lyricPaddingLeft'),
},
{
control: (
component: (
<Slider
defaultValue={displaySettings.paddingRight ?? 0}
label={(value) => `${value}%`}
marks={[
{ label: '0', value: 0 },
{ label: '25', value: 25 },
{ label: '50', value: 50 },
]}
max={50}
min={0}
onChangeEnd={(value) => {
updateDisplaySetting({ paddingRight: value });
}}
step={1}
w={100}
w="100%"
/>
),
description: '',
title: t('page.fullscreenPlayer.config.lyricPaddingRight'),
description: t('page.fullscreenPlayer.config.lyricPaddingRight', {
context: 'description',
}),
id: 'paddingRight',
label: t('page.fullscreenPlayer.config.lyricPaddingRight'),
},
{
control: (
component: (
<SegmentedControl
data={[
{ label: t('common.left'), value: 'left' },
@@ -196,41 +232,57 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
updateLyricsSetting({ alignment: value as 'center' | 'left' | 'right' })
}
value={lyricsSettings.alignment}
w="100%"
/>
),
description: '',
title: t('page.fullscreenPlayer.config.lyricAlignment'),
description: t('page.fullscreenPlayer.config.lyricAlignment', {
context: 'description',
}),
id: 'alignment',
label: t('page.fullscreenPlayer.config.lyricAlignment'),
},
{
control: (
<Switch
aria-label="Follow lyrics"
defaultChecked={lyricsSettings.follow}
onChange={(e) => updateLyricsSetting({ follow: e.currentTarget.checked })}
component: (
<ListConfigBooleanControl
onChange={(value) => updateLyricsSetting({ follow: value })}
value={lyricsSettings.follow}
/>
),
description: '',
title: t('page.fullscreenPlayer.config.followCurrentLyric'),
description: t('page.fullscreenPlayer.config.followCurrentLyric', {
context: 'description',
}),
id: 'follow',
label: t('page.fullscreenPlayer.config.followCurrentLyric'),
},
{
control: (
component: (
<Slider
defaultValue={lyricsSettings.followScrollAlignment ?? 0}
label={(value) => value.toString()}
marks={[
{ label: '-50', value: -50 },
{ label: '-25', value: -25 },
{ label: '0', value: 0 },
{ label: '25', value: 25 },
{ label: '50', value: 50 },
]}
max={50}
min={-50}
onChangeEnd={(value) => {
updateLyricsSetting({ followScrollAlignment: value });
}}
step={1}
w={100}
w="100%"
/>
),
description: '',
title: t('page.fullscreenPlayer.config.lyricFollowScrollAlignment'),
description: t('page.fullscreenPlayer.config.lyricFollowScrollAlignment', {
context: 'description',
}),
id: 'followScrollAlignment',
label: t('page.fullscreenPlayer.config.lyricFollowScrollAlignment'),
},
{
control: (
component: (
<NumberInput
defaultValue={lyricsSettings.lineLeadTimeMs}
max={3000}
@@ -243,14 +295,24 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
width={100}
/>
),
description: '',
title: t('page.fullscreenPlayer.config.lyricLineLeadTime'),
description: t('page.fullscreenPlayer.config.lyricLineLeadTime', {
context: 'description',
}),
id: 'lineLeadTimeMs',
label: t('page.fullscreenPlayer.config.lyricLineLeadTime'),
},
{
control: (
component: (
<Slider
defaultValue={displaySettings.opacityNonActive}
label={(e) => (e * 100).toFixed(0) + '%'}
marks={[
{ label: '0%', value: 0 },
{ label: '25%', value: 0.25 },
{ label: '50%', value: 0.5 },
{ label: '75%', value: 0.75 },
{ label: '100%', value: 1 },
]}
max={1.0}
min={0.0}
onChangeEnd={(e) => {
@@ -259,17 +321,25 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
});
}}
step={0.01}
w={100}
w="100%"
/>
),
description: '',
title: t(`${t('page.fullscreenPlayer.config.lyricOpacityNonActive')}`, {}),
description: t('page.fullscreenPlayer.config.lyricOpacityNonActive', {
context: 'description',
}),
id: 'opacityNonActive',
label: t('page.fullscreenPlayer.config.lyricOpacityNonActive'),
},
{
control: (
component: (
<Slider
defaultValue={displaySettings.scaleNonActive}
label={(e) => (e * 100).toFixed(0) + '%'}
marks={[
{ label: '50%', value: 0.5 },
{ label: '75%', value: 0.75 },
{ label: '100%', value: 1 },
]}
max={1.0}
min={0.5}
onChangeEnd={(e) => {
@@ -278,69 +348,74 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
});
}}
step={0.01}
w={100}
w="100%"
/>
),
description: '',
title: t(`${t('page.fullscreenPlayer.config.lyricScaleNonActive')}`, {}),
description: t('page.fullscreenPlayer.config.lyricScaleNonActive', {
context: 'description',
}),
id: 'scaleNonActive',
label: t('page.fullscreenPlayer.config.lyricScaleNonActive'),
},
{
control: (
<Switch
aria-label="Show match"
defaultChecked={lyricsSettings.showMatch}
onChange={(e) => updateLyricsSetting({ showMatch: e.currentTarget.checked })}
component: (
<ListConfigBooleanControl
onChange={(value) => updateLyricsSetting({ showMatch: value })}
value={lyricsSettings.showMatch}
/>
),
description: '',
title: t('page.fullscreenPlayer.config.showLyricMatch'),
description: t('page.fullscreenPlayer.config.showLyricMatch', {
context: 'description',
}),
id: 'showMatch',
label: t('page.fullscreenPlayer.config.showLyricMatch'),
},
{
control: (
<Switch
aria-label="Show provider"
defaultChecked={lyricsSettings.showProvider}
onChange={(e) => updateLyricsSetting({ showProvider: e.currentTarget.checked })}
component: (
<ListConfigBooleanControl
onChange={(value) => updateLyricsSetting({ showProvider: value })}
value={lyricsSettings.showProvider}
/>
),
description: '',
title: t('page.fullscreenPlayer.config.showLyricProvider'),
description: t('page.fullscreenPlayer.config.showLyricProvider', {
context: 'description',
}),
id: 'showProvider',
label: t('page.fullscreenPlayer.config.showLyricProvider'),
},
];
const lyricOptions: SettingOption[] = [
const lyricOptions = [
{
control: (
<Switch
aria-label="Prefer local lyrics"
defaultChecked={lyricsSettings.preferLocalLyrics}
onChange={(e) =>
updateLyricsSetting({ preferLocalLyrics: e.currentTarget.checked })
}
component: (
<ListConfigBooleanControl
onChange={(value) => updateLyricsSetting({ preferLocalLyrics: value })}
value={lyricsSettings.preferLocalLyrics}
/>
),
description: t('setting.preferLocalLyrics', {
context: 'description',
}),
id: 'preferLocalLyrics',
isHidden: !isElectron(),
title: t('setting.preferLocalLyrics'),
label: t('setting.preferLocalLyrics'),
},
{
control: (
<Switch
aria-label="Enable fetching lyrics"
defaultChecked={lyricsSettings.fetch}
onChange={(e) => updateLyricsSetting({ fetch: e.currentTarget.checked })}
component: (
<ListConfigBooleanControl
onChange={(value) => updateLyricsSetting({ fetch: value })}
value={lyricsSettings.fetch}
/>
),
description: t('setting.lyricFetch', {
context: 'description',
}),
id: 'fetch',
isHidden: !isElectron(),
title: t('setting.lyricFetch'),
label: t('setting.lyricFetch'),
},
{
control: (
component: (
<MultiSelect
aria-label="Lyric providers"
clearable
@@ -350,97 +425,96 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
localSettings?.set('lyrics', e);
updateLyricsSetting({ sources: e.map((source) => source as LyricSource) });
}}
width={300}
width="100%"
/>
),
description: t('setting.lyricFetchProvider', {
context: 'description',
}),
id: 'sources',
isHidden: !isElectron(),
title: t('setting.lyricFetchProvider'),
label: t('setting.lyricFetchProvider'),
},
{
control: (
<Switch
aria-label="Enable furigana"
defaultChecked={lyricsSettings.enableFurigana}
onChange={(e) =>
updateLyricsSetting({ enableFurigana: e.currentTarget.checked })
}
component: (
<ListConfigBooleanControl
onChange={(value) => updateLyricsSetting({ enableFurigana: value })}
value={lyricsSettings.enableFurigana ?? false}
/>
),
description: t('setting.enableFurigana', {
context: 'description',
}),
title: t('setting.enableFurigana'),
id: 'enableFurigana',
label: t('setting.enableFurigana'),
},
{
control: (
<Switch
aria-label="Enable romaji"
defaultChecked={lyricsSettings.enableRomaji}
onChange={(e) => updateLyricsSetting({ enableRomaji: e.currentTarget.checked })}
component: (
<ListConfigBooleanControl
onChange={(value) => updateLyricsSetting({ enableRomaji: value })}
value={lyricsSettings.enableRomaji ?? false}
/>
),
description: t('setting.enableRomaji', {
context: 'description',
}),
title: t('setting.enableRomaji'),
id: 'enableRomaji',
label: t('setting.enableRomaji'),
},
{
control: (
<Switch
aria-label="Enable NetEase translations"
defaultChecked={lyricsSettings.enableNeteaseTranslation}
onChange={(e) => {
const isChecked = e.currentTarget.checked;
updateLyricsSetting({ enableNeteaseTranslation: isChecked });
localSettings?.set('enableNeteaseTranslation', isChecked);
component: (
<ListConfigBooleanControl
onChange={(value) => {
updateLyricsSetting({ enableNeteaseTranslation: value });
localSettings?.set('enableNeteaseTranslation', value);
}}
value={lyricsSettings.enableNeteaseTranslation}
/>
),
description: t('setting.neteaseTranslation', {
context: 'description',
}),
id: 'enableNeteaseTranslation',
isHidden: !isElectron(),
title: t('setting.neteaseTranslation'),
label: t('setting.neteaseTranslation'),
},
];
const translationSettings = [
{
control: (
<NumberInput
defaultValue={lyricsSettings.delayMs}
onBlur={(e) => {
const value = Number(e.currentTarget.value);
updateLyricsSetting({ delayMs: value });
}}
step={10}
width={100}
component: (
<ListConfigBooleanControl
onChange={(value) => updateLyricsSetting({ enableAutoTranslation: value })}
value={lyricsSettings.enableAutoTranslation}
/>
),
description: t('setting.lyricOffset', {
description: t('setting.enableAutoTranslation', {
context: 'description',
}),
id: 'enableAutoTranslation',
isHidden: !isElectron(),
title: t('setting.lyricOffset'),
label: t('setting.enableAutoTranslation'),
},
{
control: (
component: (
<Select
data={languages}
onChange={(value) => {
updateLyricsSetting({ translationTargetLanguage: value });
}}
value={lyricsSettings.translationTargetLanguage}
width="100%"
/>
),
description: t('setting.translationTargetLanguage', {
context: 'description',
}),
id: 'translationTargetLanguage',
isHidden: !isElectron(),
title: t('setting.translationTargetLanguage'),
label: t('setting.translationTargetLanguage'),
},
{
control: (
component: (
<Select
clearable
data={['Microsoft Azure', 'Google Cloud']}
@@ -448,54 +522,45 @@ export const LyricsSettingsForm = ({ settingsKey }: LyricsSettingsFormProps) =>
updateLyricsSetting({ translationApiProvider: value });
}}
value={lyricsSettings.translationApiProvider}
width="100%"
/>
),
description: t('setting.translationApiProvider', {
context: 'description',
}),
id: 'translationApiProvider',
isHidden: !isElectron(),
title: t('setting.translationApiProvider'),
label: t('setting.translationApiProvider'),
},
{
control: (
component: (
<TextInput
onChange={(e) => {
updateLyricsSetting({ translationApiKey: e.currentTarget.value });
}}
value={lyricsSettings.translationApiKey}
width="100%"
/>
),
description: t('setting.translationApiKey', {
context: 'description',
}),
id: 'translationApiKey',
isHidden: !isElectron(),
title: t('setting.translationApiKey'),
},
{
control: (
<Switch
aria-label="Enable auto translation"
defaultChecked={lyricsSettings.enableAutoTranslation}
onChange={(e) =>
updateLyricsSetting({ enableAutoTranslation: e.currentTarget.checked })
}
/>
),
description: t('setting.enableAutoTranslation', {
context: 'description',
}),
isHidden: !isElectron(),
title: t('setting.enableAutoTranslation'),
label: t('setting.translationApiKey'),
},
];
return (
<Stack gap="md" p="md">
<Fieldset legend={t('page.setting.lyricsDisplay')}>
<SettingsSection options={displayOptions} />
</Fieldset>
<Stack gap="sm">
<Fieldset legend={t('page.setting.lyrics')}>
<SettingsSection options={lyricOptions} />
<ListConfigTable options={lyricOptions} />
</Fieldset>
<Fieldset legend={t('page.setting.lyricsDisplay')}>
<ListConfigTable options={displayOptions} />
</Fieldset>
<Fieldset legend={t('page.setting.lyricsTranslation')}>
<ListConfigTable options={translationSettings} />
</Fieldset>
</Stack>
);
@@ -54,7 +54,6 @@ import { SegmentedControl } from '/@/shared/components/segmented-control/segment
import { Select } from '/@/shared/components/select/select';
import { Slider } from '/@/shared/components/slider/slider';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useMediaQuery } from '/@/shared/hooks/use-media-query';
import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';
import { useThrottledValue } from '/@/shared/hooks/use-throttled-value';
@@ -201,17 +200,9 @@ const AutoDJButton = () => {
w="96px"
/>
),
description: t('setting.autoDJ_itemCount_description'),
id: 'itemCount',
label: (
<Stack gap="xs">
<Text isNoSelect size="sm">
{t('setting.autoDJ_itemCount')}
</Text>
<Text isMuted isNoSelect size="xs">
{t('setting.autoDJ_itemCount_description')}
</Text>
</Stack>
),
label: t('setting.autoDJ_itemCount'),
},
{
component: (
@@ -239,17 +230,9 @@ const AutoDJButton = () => {
w="144px"
/>
),
description: t('setting.autoDJ_timing_description'),
id: 'timing',
label: (
<Stack gap="xs">
<Text isNoSelect size="sm">
{t('setting.autoDJ_timing')}
</Text>
<Text isMuted isNoSelect size="xs">
{t('setting.autoDJ_timing_description')}
</Text>
</Stack>
),
label: t('setting.autoDJ_timing'),
},
],
[
@@ -279,17 +262,9 @@ const AutoDJButton = () => {
value={settings.allowDuplicates}
/>
),
description: t('setting.autoDJ_allowDuplicates_description'),
id: 'allowDuplicates',
label: (
<Stack gap="xs">
<Text isNoSelect size="sm">
{t('setting.autoDJ_allowDuplicates')}
</Text>
<Text isMuted isNoSelect size="xs">
{t('setting.autoDJ_allowDuplicates_description')}
</Text>
</Stack>
),
label: t('setting.autoDJ_allowDuplicates'),
},
{
component: (
@@ -304,17 +279,9 @@ const AutoDJButton = () => {
value={settings.onlySimilar}
/>
),
description: t('setting.autoDJ_onlySimilar_description'),
id: 'onlySimilar',
label: (
<Stack gap="xs">
<Text isNoSelect size="sm">
{t('setting.autoDJ_onlySimilar')}
</Text>
<Text isMuted isNoSelect size="xs">
{t('setting.autoDJ_onlySimilar_description')}
</Text>
</Stack>
),
label: t('setting.autoDJ_onlySimilar'),
},
],
[setSettings, settings.allowDuplicates, settings.onlySimilar, t],
@@ -6,13 +6,13 @@
width: 50%;
padding: var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md) 0;
font-weight: 500;
vertical-align: middle;
vertical-align: top;
background-color: initial;
}
.td {
padding: 0;
vertical-align: middle;
vertical-align: top;
}
.control {
@@ -20,6 +20,7 @@
align-items: center;
justify-content: flex-end;
width: 100%;
padding: var(--theme-spacing-md) 0;
}
.divider-cell {
@@ -17,6 +17,7 @@ import { SegmentedControl } from '/@/shared/components/segmented-control/segment
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { Table } from '/@/shared/components/table/table';
import { Text } from '/@/shared/components/text/text';
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
@@ -232,6 +233,7 @@ export const ListConfigTable = ({
}: {
options: {
component: ReactNode;
description?: ReactNode | string;
id: string;
isDivider?: boolean;
isHidden?: boolean;
@@ -269,7 +271,20 @@ export const ListConfigTable = ({
return (
<Table.Tr key={option.id}>
<Table.Th>{option.label}</Table.Th>
<Table.Th>
{option.description !== undefined ? (
<Stack gap="xs">
<Text isNoSelect size="sm">
{option.label}
</Text>
<Text isMuted isNoSelect size="xs">
{option.description}
</Text>
</Stack>
) : (
option.label
)}
</Table.Th>
<Table.Td>
<div className={styles.control}>{option.component}</div>
</Table.Td>