feishin/src/renderer/features/settings/components/playback/mpv-settings.tsx
Ante Budimir 59e94318bb feat: add audio channel configuration for MPV player
- Add audio channels setting (auto/mono/stereo) to MPV properties
- Implement UI controls in settings panel and player right controls
- Enable immediate MPV property application when setting changes
- Update default playback type to LOCAL and sample rate to 48kHz
2025-11-27 20:16:06 +02:00

445 lines
16 KiB
TypeScript

import isElectron from 'is-electron';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { usePlayerControls, usePlayerStore, useQueueControls } from '/@/renderer/store';
import {
SettingsState,
usePlaybackSettings,
useSettingsStore,
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { Select } from '/@/shared/components/select/select';
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 { Textarea } from '/@/shared/components/textarea/textarea';
import { PlaybackType } from '/@/shared/types/types';
const localSettings = isElectron() ? window.api.localSettings : null;
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
export const getMpvSetting = (
key: keyof SettingsState['playback']['mpvProperties'],
value: any,
) => {
switch (key) {
case 'audioChannels':
return { 'audio-channels': value };
case 'audioExclusiveMode':
return { 'audio-exclusive': value || 'no' };
case 'audioSampleRateHz':
return { 'audio-samplerate': value };
case 'gaplessAudio':
return { 'gapless-audio': value || 'weak' };
case 'replayGainClip':
return { 'replaygain-clip': value || 'no' };
case 'replayGainFallbackDB':
return { 'replaygain-fallback': value };
case 'replayGainMode':
return { replaygain: value || 'no' };
case 'replayGainPreampDB':
return { 'replaygain-preamp': value || 0 };
default:
return { 'audio-format': value };
}
};
export const getMpvProperties = (settings: SettingsState['playback']['mpvProperties']) => {
const properties: Record<string, any> = {
'audio-channels': settings.audioChannels || 'stereo',
'audio-exclusive': settings.audioExclusiveMode || 'no',
'audio-samplerate':
settings.audioSampleRateHz === 0 ? undefined : settings.audioSampleRateHz,
'gapless-audio': settings.gaplessAudio || 'weak',
replaygain: settings.replayGainMode || 'no',
'replaygain-clip': settings.replayGainClip || 'no',
'replaygain-fallback': settings.replayGainFallbackDB,
'replaygain-preamp': settings.replayGainPreampDB || 0,
};
Object.keys(properties).forEach((key) =>
properties[key] === undefined ? delete properties[key] : {},
);
return properties;
};
export const MpvSettings = () => {
const { t } = useTranslation();
const settings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions();
const { pause } = usePlayerControls();
const { clearQueue } = useQueueControls();
const [mpvPath, setMpvPath] = useState(
(localSettings?.get('mpv_path') as string | undefined) || '',
);
const handleSetMpvPath = async (clear?: boolean) => {
if (clear) {
localSettings?.set('mpv_path', undefined);
setMpvPath('');
return;
}
const result = await localSettings?.openFileSelector();
if (result === null) {
localSettings?.set('mpv_path', undefined);
setMpvPath('');
return;
}
localSettings?.set('mpv_path', result);
setMpvPath(result);
};
useEffect(() => {
const getMpvPath = async () => {
if (!localSettings) return setMpvPath('');
const mpvPath = (await localSettings.get('mpv_path')) as string;
return setMpvPath(mpvPath);
};
getMpvPath();
}, []);
const handleSetMpvProperty = (
setting: keyof SettingsState['playback']['mpvProperties'],
value: any,
) => {
setSettings({
playback: {
...settings,
mpvProperties: {
...settings.mpvProperties,
[setting]: value,
},
},
});
const mpvSetting = getMpvSetting(setting, value);
mpvPlayer?.setProperties(mpvSetting);
};
const handleReloadMpv = () => {
pause();
clearQueue();
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
const properties: Record<string, any> = {
speed: usePlayerStore.getState().speed,
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
};
mpvPlayer?.restart({
binaryPath: mpvPath || undefined,
extraParameters,
properties,
});
};
const handleSetExtraParameters = (data: string[]) => {
setSettings({
playback: {
...settings,
mpvExtraParameters: data,
},
});
};
const options: SettingOption[] = [
{
control: (
<Group gap="sm">
<ActionIcon
icon="refresh"
onClick={handleReloadMpv}
tooltip={{
label: t('common.reload', { postProcess: 'titleCase' }),
openDelay: 0,
}}
variant="subtle"
/>
<TextInput
onChange={(e) => {
setMpvPath(e.currentTarget.value);
// Transform backslashes to forward slashes
const transformedValue = e.currentTarget.value.replace(/\\/g, '/');
localSettings?.set('mpv_path', transformedValue);
}}
onClick={() => handleSetMpvPath()}
rightSection={
mpvPath && (
<ActionIcon
icon="x"
onClick={() => handleSetMpvPath(true)}
variant="transparent"
/>
)
}
value={mpvPath}
width={200}
/>
</Group>
),
description: t('setting.mpvExecutablePath', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlaybackType.LOCAL,
note: 'Restart required',
title: t('setting.mpvExecutablePath', { postProcess: 'sentenceCase' }),
},
{
control: (
<Stack gap="xs">
<Textarea
autosize
defaultValue={settings.mpvExtraParameters.join('\n')}
minRows={4}
onBlur={(e) => {
handleSetExtraParameters(e.currentTarget.value.split('\n'));
}}
placeholder={`(${t('setting.mpvExtraParameters', {
context: 'help',
postProcess: 'sentenceCase',
})}):\n--gapless-audio=weak\n--prefetch-playlist=yes`}
width={225}
/>
</Stack>
),
description: (
<Stack gap={0}>
<Text isMuted isNoSelect size="sm">
{t('setting.mpvExtraParameters', {
context: 'description',
postProcess: 'sentenceCase',
})}
</Text>
<Text size="sm">
<a
href="https://mpv.io/manual/stable/#audio"
rel="noreferrer"
target="_blank"
>
https://mpv.io/manual/stable/#audio
</a>
</Text>
</Stack>
),
isHidden: settings.type !== PlaybackType.LOCAL,
note: t('common.restartRequired', {
postProcess: 'sentenceCase',
}),
title: t('setting.mpvExtraParameters', {
postProcess: 'sentenceCase',
}),
},
];
const generalOptions: SettingOption[] = [
{
control: (
<Select
data={[
{ label: t('common.no', { postProcess: 'titleCase' }), value: 'no' },
{ label: t('common.yes', { postProcess: 'titleCase' }), value: 'yes' },
{
label: t('setting.gaplessAudio', {
context: 'optionWeak',
postProcess: 'sentenceCase',
}),
value: 'weak',
},
]}
defaultValue={settings.mpvProperties.gaplessAudio}
onChange={(e) => handleSetMpvProperty('gaplessAudio', e)}
/>
),
description: t('setting.gaplessAudio', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlaybackType.LOCAL,
title: t('setting.gaplessAudio', { postProcess: 'sentenceCase' }),
},
{
control: (
<Select
data={[
{ label: 'Mono', value: 'mono' },
{ label: 'Stereo', value: 'stereo' },
]}
defaultValue={settings.mpvProperties.audioChannels || 'stereo'}
onChange={(e) => handleSetMpvProperty('audioChannels', e)}
/>
),
description:
'Select the audio channel mode. Stereo is the default, mono downmixes to a single channel.',
isHidden: settings.type !== PlaybackType.LOCAL,
title: 'Audio Channels',
},
{
control: (
<NumberInput
defaultValue={settings.mpvProperties.audioSampleRateHz || undefined}
max={192000}
min={0}
onBlur={(e) => {
const value = Number(e.currentTarget.value);
// Setting a value of `undefined` causes an error for MPV. Use 0 instead
handleSetMpvProperty('audioSampleRateHz', value >= 8000 ? value : value);
}}
placeholder="48000"
rightSection={<Text size="xs">Hz</Text>}
width={100}
/>
),
description: t('setting.sampleRate', {
context: 'description',
postProcess: 'sentenceCase',
}),
note: 'Page refresh required for web player',
title: t('setting.sampleRate', { postProcess: 'sentenceCase' }),
},
{
control: (
<Switch
defaultChecked={settings.mpvProperties.audioExclusiveMode === 'yes'}
onChange={(e) =>
handleSetMpvProperty(
'audioExclusiveMode',
e.currentTarget.checked ? 'yes' : 'no',
)
}
/>
),
description: t('setting.audioExclusiveMode', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlaybackType.LOCAL,
title: t('setting.audioExclusiveMode', { postProcess: 'sentenceCase' }),
},
];
const replayGainOptions: SettingOption[] = [
{
control: (
<Select
data={[
{
label: t('setting.replayGainMode', {
context: 'optionNone',
postProcess: 'titleCase',
}),
value: 'no',
},
{
label: t('setting.replayGainMode', {
context: 'optionTrack',
postProcess: 'titleCase',
}),
value: 'track',
},
{
label: t('setting.replayGainMode', {
context: 'optionAlbum',
postProcess: 'titleCase',
}),
value: 'album',
},
]}
defaultValue={settings.mpvProperties.replayGainMode}
onChange={(e) => handleSetMpvProperty('replayGainMode', e)}
/>
),
description: t('setting.replayGainMode', {
context: 'description',
postProcess: 'sentenceCase',
ReplayGain: 'ReplayGain',
}),
note: t('common.restartRequired', { postProcess: 'sentenceCase' }),
title: t('setting.replayGainMode', {
postProcess: 'sentenceCase',
ReplayGain: 'ReplayGain',
}),
},
{
control: (
<NumberInput
defaultValue={settings.mpvProperties.replayGainPreampDB}
onChange={(e) => handleSetMpvProperty('replayGainPreampDB', e)}
width={75}
/>
),
description: t('setting.replayGainMode', {
context: 'description',
postProcess: 'sentenceCase',
ReplayGain: 'ReplayGain',
}),
title: t('setting.replayGainPreamp', {
postProcess: 'sentenceCase',
ReplayGain: 'ReplayGain',
}),
},
{
control: (
<Switch
defaultChecked={settings.mpvProperties.replayGainClip}
onChange={(e) =>
handleSetMpvProperty('replayGainClip', e.currentTarget.checked)
}
/>
),
description: t('setting.replayGainClipping', {
context: 'description',
postProcess: 'sentenceCase',
ReplayGain: 'ReplayGain',
}),
title: t('setting.replayGainClipping', {
postProcess: 'sentenceCase',
ReplayGain: 'ReplayGain',
}),
},
{
control: (
<NumberInput
defaultValue={settings.mpvProperties.replayGainFallbackDB}
onBlur={(e) =>
handleSetMpvProperty('replayGainFallbackDB', Number(e.currentTarget.value))
}
width={75}
/>
),
description: t('setting.replayGainFallback', {
postProcess: 'sentenceCase',
ReplayGain: 'ReplayGain',
}),
title: t('setting.replayGainFallback', {
postProcess: 'sentenceCase',
ReplayGain: 'ReplayGain',
}),
},
];
return (
<>
<SettingsSection options={options} />
<SettingsSection options={generalOptions} />
<SettingsSection options={replayGainOptions} />
</>
);
};