Refactor settings store and components

This commit is contained in:
jeffvli 2023-03-30 06:44:33 -07:00
parent 373441e4c6
commit eecbcddea3
30 changed files with 894 additions and 832 deletions

View file

@ -1,278 +0,0 @@
import { Divider, Stack } from '@mantine/core';
import { Select, Slider, Switch } from '/@/renderer/components';
import isElectron from 'is-electron';
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
import { THEME_DATA } from '/@/renderer/hooks';
import {
useGeneralSettings,
useSettingsStoreActions,
SideQueueType,
} from '/@/renderer/store/settings.store';
import { AppTheme } from '/@/renderer/themes/types';
import { Platform } from '/@/renderer/types';
const FONT_OPTIONS = [
{ label: 'Archivo', value: 'Archivo' },
{ label: 'Fredoka', value: 'Fredoka' },
{ label: 'League Spartan', value: 'League Spartan' },
{ label: 'Lexend', value: 'Lexend' },
{ label: 'Poppins', value: 'Poppins' },
{ label: 'Raleway', value: 'Raleway' },
{ label: 'Sora', value: 'Sora' },
{ label: 'Work Sans', value: 'Work Sans' },
];
const SIDE_QUEUE_OPTIONS = [
{ label: 'Fixed', value: 'sideQueue' },
{ label: 'Floating', value: 'sideDrawerQueue' },
];
const WINDOW_BAR_OPTIONS = [
{ label: 'Web (hidden)', value: Platform.WEB },
{ label: 'Windows', value: Platform.WINDOWS },
{ label: 'macOS', value: Platform.MACOS },
];
export const GeneralTab = () => {
const settings = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
const options = [
{
control: (
<Select
data={WINDOW_BAR_OPTIONS}
disabled={!isElectron()}
value={settings.windowBarStyle}
onChange={(e) => {
if (!e) return;
setSettings({
general: {
...settings,
windowBarStyle: e as Platform,
},
});
}}
/>
),
description: 'Adjust the style of the application window bar',
isHidden: !isElectron(),
title: 'Window bar style',
},
{
control: (
<Select
disabled
data={[]}
/>
),
description: 'Sets the application language',
isHidden: false,
title: 'Language',
},
{
control: (
<Select
searchable
data={FONT_OPTIONS}
defaultValue={settings.fontContent}
onChange={(e) => {
if (!e) return;
setSettings({
general: {
...settings,
fontContent: e,
},
});
}}
/>
),
description: 'Sets the application content font',
isHidden: false,
title: 'Font (Content)',
},
];
const themeOptions = [
{
control: (
<Switch
defaultChecked={settings.followSystemTheme}
onChange={(e) => {
setSettings({
general: {
...settings,
followSystemTheme: e.currentTarget.checked,
},
});
}}
/>
),
description: 'Follows the system-defined light or dark preference',
isHidden: false,
title: 'Use system theme',
},
{
control: (
<Select
data={THEME_DATA}
defaultValue={settings.theme}
onChange={(e) => {
setSettings({
general: {
...settings,
theme: e as AppTheme,
},
});
}}
/>
),
description: 'Sets the default theme',
isHidden: settings.followSystemTheme,
title: 'Theme',
},
{
control: (
<Select
data={THEME_DATA}
defaultValue={settings.themeDark}
onChange={(e) => {
setSettings({
general: {
...settings,
themeDark: e as AppTheme,
},
});
}}
/>
),
description: 'Sets the dark theme',
isHidden: !settings.followSystemTheme,
title: 'Theme (dark)',
},
{
control: (
<Select
data={THEME_DATA}
defaultValue={settings.themeLight}
onChange={(e) => {
setSettings({
general: {
...settings,
themeLight: e as AppTheme,
},
});
}}
/>
),
description: 'Sets the light theme',
isHidden: !settings.followSystemTheme,
title: 'Theme (light)',
},
];
const layoutOptions = [
{
control: (
<Select
data={SIDE_QUEUE_OPTIONS}
defaultValue={settings.sideQueueType}
onChange={(e) => {
setSettings({
general: {
...settings,
sideQueueType: e as SideQueueType,
},
});
}}
/>
),
description: 'The style of the sidebar play queue',
isHidden: false,
title: 'Side play queue style',
},
{
control: (
<Switch
defaultChecked={settings.showQueueDrawerButton}
onChange={(e) => {
setSettings({
general: {
...settings,
showQueueDrawerButton: e.currentTarget.checked,
},
});
}}
/>
),
description: 'Display a hover icon on the right side of the application view the play queue',
isHidden: false,
title: 'Show floating queue hover area',
},
];
const miscOptions = [
{
control: (
<Slider
defaultValue={settings.volumeWheelStep}
max={20}
min={1}
w={100}
onChangeEnd={(e) => {
setSettings({
general: {
...settings,
volumeWheelStep: e,
},
});
}}
/>
),
description:
'The amount of volume to change when scrolling the mouse wheel on the volume slider',
isHidden: false,
title: 'Volume wheel step',
},
];
return (
<Stack spacing="md">
{options
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`general-${option.title}`}
{...option}
/>
))}
<Divider />
{themeOptions
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`general-${option.title}`}
{...option}
/>
))}
<Divider />
{layoutOptions
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`general-${option.title}`}
{...option}
/>
))}
<Divider />
{miscOptions
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`general-${option.title}`}
{...option}
/>
))}
</Stack>
);
};

View file

@ -0,0 +1,59 @@
import { Select } from '/@/renderer/components';
import {
SettingsSection,
SettingOption,
} from '/@/renderer/features/settings/components/settings-section';
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
const FONT_OPTIONS = [
{ label: 'Archivo', value: 'Archivo' },
{ label: 'Fredoka', value: 'Fredoka' },
{ label: 'League Spartan', value: 'League Spartan' },
{ label: 'Lexend', value: 'Lexend' },
{ label: 'Poppins', value: 'Poppins' },
{ label: 'Raleway', value: 'Raleway' },
{ label: 'Sora', value: 'Sora' },
{ label: 'Work Sans', value: 'Work Sans' },
];
export const ApplicationSettings = () => {
const settings = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
const options: SettingOption[] = [
{
control: (
<Select
disabled
data={[]}
/>
),
description: 'Sets the application language',
isHidden: false,
title: 'Language',
},
{
control: (
<Select
searchable
data={FONT_OPTIONS}
defaultValue={settings.fontContent}
onChange={(e) => {
if (!e) return;
setSettings({
general: {
...settings,
fontContent: e,
},
});
}}
/>
),
description: 'Sets the application content font',
isHidden: false,
title: 'Font (Content)',
},
];
return <SettingsSection options={options} />;
};

View file

@ -0,0 +1,176 @@
import { Group } from '@mantine/core';
import { Select, Tooltip, NumberInput, Switch, Slider } from '/@/renderer/components';
import { SettingsSection } from '/@/renderer/features/settings/components/settings-section';
import {
SideQueueType,
useGeneralSettings,
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
import { Play } from '/@/renderer/types';
const SIDE_QUEUE_OPTIONS = [
{ label: 'Fixed', value: 'sideQueue' },
{ label: 'Floating', value: 'sideDrawerQueue' },
];
export const ControlSettings = () => {
const settings = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
const controlOptions = [
{
control: (
<Switch
aria-label="Toggle skip buttons"
defaultChecked={settings.skipButtons?.enabled}
onChange={(e) =>
setSettings({
general: {
...settings,
skipButtons: {
...settings.skipButtons,
enabled: e.currentTarget.checked,
},
},
})
}
/>
),
description: 'Show or hide the skip buttons on the playerbar',
isHidden: false,
title: 'Show skip buttons',
},
{
control: (
<Group>
<Tooltip label="Backward">
<NumberInput
defaultValue={settings.skipButtons.skipBackwardSeconds}
min={0}
width={75}
onBlur={(e) =>
setSettings({
general: {
...settings,
skipButtons: {
...settings.skipButtons,
skipBackwardSeconds: e.currentTarget.value
? Number(e.currentTarget.value)
: 0,
},
},
})
}
/>
</Tooltip>
<Tooltip label="Forward">
<NumberInput
defaultValue={settings.skipButtons.skipForwardSeconds}
min={0}
width={75}
onBlur={(e) =>
setSettings({
general: {
...settings,
skipButtons: {
...settings.skipButtons,
skipForwardSeconds: e.currentTarget.value ? Number(e.currentTarget.value) : 0,
},
},
})
}
/>
</Tooltip>
</Group>
),
description:
'The number (in seconds) to skip forward or backward when using the skip buttons',
isHidden: false,
title: 'Skip duration',
},
{
control: (
<Select
data={[
{ label: 'Now', value: Play.NOW },
{ label: 'Next', value: Play.NEXT },
{ label: 'Last', value: Play.LAST },
]}
defaultValue={settings.playButtonBehavior}
onChange={(e) =>
setSettings({
general: {
...settings,
playButtonBehavior: e as Play,
},
})
}
/>
),
description: 'The default behavior of the play button when adding songs to the queue',
isHidden: false,
title: 'Play button behavior',
},
{
control: (
<Select
data={SIDE_QUEUE_OPTIONS}
defaultValue={settings.sideQueueType}
onChange={(e) => {
setSettings({
general: {
...settings,
sideQueueType: e as SideQueueType,
},
});
}}
/>
),
description: 'The style of the sidebar play queue',
isHidden: false,
title: 'Side play queue style',
},
{
control: (
<Switch
defaultChecked={settings.showQueueDrawerButton}
onChange={(e) => {
setSettings({
general: {
...settings,
showQueueDrawerButton: e.currentTarget.checked,
},
});
}}
/>
),
description: 'Display a hover icon on the right side of the application view the play queue',
isHidden: false,
title: 'Show floating queue hover area',
},
{
control: (
<Slider
defaultValue={settings.volumeWheelStep}
max={20}
min={1}
w={100}
onChangeEnd={(e) => {
setSettings({
general: {
...settings,
volumeWheelStep: e,
},
});
}}
/>
),
description:
'The amount of volume to change when scrolling the mouse wheel on the volume slider',
isHidden: false,
title: 'Volume wheel step',
},
];
return <SettingsSection options={controlOptions} />;
};

View file

@ -0,0 +1,16 @@
import { Divider, Stack } from '@mantine/core';
import { ApplicationSettings } from '/@/renderer/features/settings/components/general/application-settings';
import { ControlSettings } from '/@/renderer/features/settings/components/general/control-settings';
import { ThemeSettings } from '/@/renderer/features/settings/components/general/theme-settings';
export const GeneralTab = () => {
return (
<Stack spacing="md">
<ApplicationSettings />
<Divider />
<ThemeSettings />
<Divider />
<ControlSettings />
</Stack>
);
};

View file

@ -0,0 +1,93 @@
import { Switch, Select } from '/@/renderer/components';
import {
SettingsSection,
SettingOption,
} from '/@/renderer/features/settings/components/settings-section';
import { THEME_DATA } from '/@/renderer/hooks';
import { useGeneralSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { AppTheme } from '/@/renderer/themes/types';
export const ThemeSettings = () => {
const settings = useGeneralSettings();
const { setSettings } = useSettingsStoreActions();
const themeOptions: SettingOption[] = [
{
control: (
<Switch
defaultChecked={settings.followSystemTheme}
onChange={(e) => {
setSettings({
general: {
...settings,
followSystemTheme: e.currentTarget.checked,
},
});
}}
/>
),
description: 'Follows the system-defined light or dark preference',
isHidden: false,
title: 'Use system theme',
},
{
control: (
<Select
data={THEME_DATA}
defaultValue={settings.theme}
onChange={(e) => {
setSettings({
general: {
...settings,
theme: e as AppTheme,
},
});
}}
/>
),
description: 'Sets the default theme',
isHidden: settings.followSystemTheme,
title: 'Theme',
},
{
control: (
<Select
data={THEME_DATA}
defaultValue={settings.themeDark}
onChange={(e) => {
setSettings({
general: {
...settings,
themeDark: e as AppTheme,
},
});
}}
/>
),
description: 'Sets the dark theme',
isHidden: !settings.followSystemTheme,
title: 'Theme (dark)',
},
{
control: (
<Select
data={THEME_DATA}
defaultValue={settings.themeLight}
onChange={(e) => {
setSettings({
general: {
...settings,
themeLight: e as AppTheme,
},
});
}}
/>
),
description: 'Sets the light theme',
isHidden: !settings.followSystemTheme,
title: 'Theme (light)',
},
];
return <SettingsSection options={themeOptions} />;
};

View file

@ -1,478 +1,13 @@
import { useEffect, useState } from 'react';
import { Divider, Group, SelectItem, Stack } from '@mantine/core';
import {
FileInput,
NumberInput,
Select,
Slider,
Switch,
Textarea,
Tooltip,
toast,
Text,
} from '/@/renderer/components';
import isElectron from 'is-electron';
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
import { useCurrentStatus, usePlayerStore } from '/@/renderer/store';
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { PlaybackType, PlayerStatus, PlaybackStyle, CrossfadeStyle, Play } from '/@/renderer/types';
const localSettings = isElectron() ? window.electron.localSettings : null;
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const getAudioDevice = async () => {
const devices = await navigator.mediaDevices.enumerateDevices();
return (devices || []).filter((dev: MediaDeviceInfo) => dev.kind === 'audiooutput');
};
import { Divider, Stack } from '@mantine/core';
import { AudioSettings } from '/@/renderer/features/settings/components/playback/audio-settings';
import { ScrobbleSettings } from '/@/renderer/features/settings/components/playback/scrobble-settings';
export const PlaybackTab = () => {
const settings = useSettingsStore((state) => state.player);
const { setSettings } = useSettingsStoreActions();
const status = useCurrentStatus();
const [audioDevices, setAudioDevices] = useState<SelectItem[]>([]);
const [mpvPath, setMpvPath] = useState('');
const [mpvParameters, setMpvParameters] = useState('');
const handleSetMpvPath = (e: File) => {
localSettings.set('mpv_path', e.path);
};
useEffect(() => {
const getMpvPath = async () => {
if (!isElectron()) return setMpvPath('');
const mpvPath = (await localSettings.get('mpv_path')) as string;
return setMpvPath(mpvPath);
};
const getMpvParameters = async () => {
if (!isElectron()) return setMpvPath('');
const mpvParametersFromSettings = (await localSettings.get('mpv_parameters')) as string[];
const mpvParameters = mpvParametersFromSettings?.join('\n');
return setMpvParameters(mpvParameters);
};
getMpvPath();
getMpvParameters();
}, []);
useEffect(() => {
const getAudioDevices = () => {
getAudioDevice()
.then((dev) => setAudioDevices(dev.map((d) => ({ label: d.label, value: d.deviceId }))))
.catch(() => toast.error({ message: 'Error fetching audio devices' }));
};
getAudioDevices();
}, []);
const playerOptions = [
{
control: (
<Select
data={[
{
disabled: !isElectron(),
label: 'MPV',
value: PlaybackType.LOCAL,
},
{ label: 'Web', value: PlaybackType.WEB },
]}
defaultValue={settings.type}
disabled={status === PlayerStatus.PLAYING}
onChange={(e) => {
setSettings({ player: { ...settings, type: e as PlaybackType } });
if (isElectron() && e === PlaybackType.LOCAL) {
const queueData = usePlayerStore.getState().actions.getPlayerData();
mpvPlayer.setQueue(queueData);
}
}}
/>
),
description: 'The audio player to use for playback',
isHidden: !isElectron(),
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Audio player',
},
{
control: (
<FileInput
placeholder={mpvPath}
width={225}
onChange={handleSetMpvPath}
/>
),
description: 'The location of your mpv executable',
isHidden: settings.type !== PlaybackType.LOCAL,
note: 'Restart required',
title: 'MPV executable path',
},
{
control: (
<Stack spacing="xs">
<Textarea
autosize
defaultValue={mpvParameters}
minRows={4}
placeholder={'(Add one per line):\n--gapless-audio=weak\n--prefetch-playlist=yes'}
width={225}
onBlur={(e) => {
if (isElectron()) {
localSettings.set('mpv_parameters', e.currentTarget.value.split('\n'));
}
}}
/>
</Stack>
),
description: (
<Stack spacing={0}>
<Text
$noSelect
$secondary
>
Options to pass to the player
</Text>
<Text>
<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: 'Restart required.',
title: 'MPV parameters',
},
{
control: (
<Select
clearable
data={audioDevices}
defaultValue={settings.audioDeviceId}
disabled={settings.type !== PlaybackType.WEB}
onChange={(e) => setSettings({ player: { ...settings, audioDeviceId: e } })}
/>
),
description: 'The audio device to use for playback (web player only)',
isHidden: !isElectron() || settings.type !== PlaybackType.WEB,
title: 'Audio device',
},
{
control: (
<Select
data={[
{ label: 'Normal', value: PlaybackStyle.GAPLESS },
{ label: 'Crossfade', value: PlaybackStyle.CROSSFADE },
]}
defaultValue={settings.style}
disabled={settings.type !== PlaybackType.WEB || status === PlayerStatus.PLAYING}
onChange={(e) => setSettings({ player: { ...settings, style: e as PlaybackStyle } })}
/>
),
description: 'Adjust the playback style (web player only)',
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Playback style',
},
{
control: (
<Slider
defaultValue={settings.crossfadeDuration}
disabled={
settings.type !== PlaybackType.WEB ||
settings.style !== PlaybackStyle.CROSSFADE ||
status === PlayerStatus.PLAYING
}
max={15}
min={0}
w={100}
onChangeEnd={(e) => setSettings({ player: { ...settings, crossfadeDuration: e } })}
/>
),
description: 'Adjust the crossfade duration (web player only)',
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Crossfade Duration',
},
{
control: (
<Select
data={[
{ label: 'Linear', value: CrossfadeStyle.LINEAR },
{ label: 'Constant Power', value: CrossfadeStyle.CONSTANT_POWER },
{
label: 'Constant Power (Slow cut)',
value: CrossfadeStyle.CONSTANT_POWER_SLOW_CUT,
},
{
label: 'Constant Power (Slow fade)',
value: CrossfadeStyle.CONSTANT_POWER_SLOW_FADE,
},
{ label: 'Dipped', value: CrossfadeStyle.DIPPED },
{ label: 'Equal Power', value: CrossfadeStyle.EQUALPOWER },
]}
defaultValue={settings.crossfadeStyle}
disabled={
settings.type !== PlaybackType.WEB ||
settings.style !== PlaybackStyle.CROSSFADE ||
status === PlayerStatus.PLAYING
}
width={200}
onChange={(e) => {
if (!e) return;
setSettings({
player: { ...settings, crossfadeStyle: e as CrossfadeStyle },
});
}}
/>
),
description: 'Change the crossfade algorithm (web player only)',
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Crossfade Style',
},
{
control: (
<Switch
aria-label="Toggle global media hotkeys"
defaultChecked={settings.globalMediaHotkeys}
disabled={!isElectron()}
onChange={(e) => {
setSettings({
player: {
...settings,
globalMediaHotkeys: e.currentTarget.checked,
},
});
localSettings.set('global_media_hotkeys', e.currentTarget.checked);
if (e.currentTarget.checked) {
localSettings.enableMediaKeys();
} else {
localSettings.disableMediaKeys();
}
}}
/>
),
description:
'Enable or disable the usage of your system media hotkeys to control the audio player (desktop only)',
isHidden: !isElectron(),
title: 'Global media hotkeys',
},
];
const scrobbleOptions = [
{
control: (
<Switch
aria-label="Toggle scrobble"
defaultChecked={settings.scrobble.enabled}
onChange={(e) => {
setSettings({
player: {
...settings,
scrobble: {
...settings.scrobble,
enabled: e.currentTarget.checked,
},
},
});
}}
/>
),
description: 'Enable or disable scrobbling to your media server',
isHidden: !isElectron(),
title: 'Scrobble',
},
{
control: (
<Slider
aria-label="Scrobble percentage"
defaultValue={settings.scrobble.scrobbleAtPercentage}
label={`${settings.scrobble.scrobbleAtPercentage}%`}
max={90}
min={25}
w={100}
onChange={(e) => {
setSettings({
player: {
...settings,
scrobble: {
...settings.scrobble,
scrobbleAtPercentage: e,
},
},
});
}}
/>
),
description: 'The percentage of the song that must be played before submitting a scrobble',
isHidden: !isElectron(),
title: 'Minimum scrobble percentage*',
},
{
control: (
<NumberInput
aria-label="Scrobble duration in seconds"
defaultValue={settings.scrobble.scrobbleAtDuration}
max={1200}
min={0}
width={75}
onChange={(e) => {
if (e === '') return;
setSettings({
player: {
...settings,
scrobble: {
...settings.scrobble,
scrobbleAtDuration: e,
},
},
});
}}
/>
),
description:
'The duration in seconds of a song that must be played before submitting a scrobble',
isHidden: !isElectron(),
title: 'Minimum scrobble duration (seconds)*',
},
];
const otherOptions = [
{
control: (
<Select
data={[
{ label: 'Now', value: Play.NOW },
{ label: 'Next', value: Play.NEXT },
{ label: 'Last', value: Play.LAST },
]}
defaultValue={settings.playButtonBehavior}
onChange={(e) =>
setSettings({
player: {
...settings,
playButtonBehavior: e as Play,
},
})
}
/>
),
description: 'The default behavior of the play button when adding songs to the queue',
isHidden: false,
title: 'Play button behavior',
},
{
control: (
<Group>
<Tooltip label="Backward">
<NumberInput
defaultValue={settings.skipButtons.skipBackwardSeconds}
min={0}
width={75}
onBlur={(e) =>
setSettings({
player: {
...settings,
skipButtons: {
...settings.skipButtons,
skipBackwardSeconds: e.currentTarget.value
? Number(e.currentTarget.value)
: 0,
},
},
})
}
/>
</Tooltip>
<Tooltip label="Forward">
<NumberInput
defaultValue={settings.skipButtons.skipForwardSeconds}
min={0}
width={75}
onBlur={(e) =>
setSettings({
player: {
...settings,
skipButtons: {
...settings.skipButtons,
skipForwardSeconds: e.currentTarget.value ? Number(e.currentTarget.value) : 0,
},
},
})
}
/>
</Tooltip>
</Group>
),
description:
'The number (in seconds) to skip forward or backward when using the skip buttons',
isHidden: false,
title: 'Skip duration',
},
{
control: (
<Switch
aria-label="Toggle skip buttons"
defaultChecked={settings.skipButtons?.enabled}
onChange={(e) =>
setSettings({
player: {
...settings,
skipButtons: {
...settings.skipButtons,
enabled: e.currentTarget.checked,
},
},
})
}
/>
),
description: 'Show or hide the skip buttons on the playerbar',
isHidden: false,
title: 'Show skip buttons',
},
];
return (
<Stack spacing="md">
{playerOptions
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`playback-${option.title}`}
{...option}
/>
))}
<AudioSettings />
<Divider />
{scrobbleOptions
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`'scrobble-${option.title}`}
{...option}
/>
))}
<Text
$secondary
size="sm"
>
*The scrobble will be submitted if one or more of the above conditions is met
</Text>
<Divider />
{otherOptions
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`playerbar-${option.title}`}
{...option}
/>
))}
<ScrobbleSettings />
</Stack>
);
};

View file

@ -0,0 +1,262 @@
import { useEffect, useState } from 'react';
import { SelectItem, Stack } from '@mantine/core';
import isElectron from 'is-electron';
import { Select, FileInput, Slider, Switch, Textarea, Text, toast } from '/@/renderer/components';
import {
SettingsSection,
SettingOption,
} from '/@/renderer/features/settings/components/settings-section';
import { useCurrentStatus, usePlayerStore } from '/@/renderer/store';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { PlaybackType, PlayerStatus, PlaybackStyle, CrossfadeStyle } from '/@/renderer/types';
const localSettings = isElectron() ? window.electron.localSettings : null;
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
const getAudioDevice = async () => {
const devices = await navigator.mediaDevices.enumerateDevices();
return (devices || []).filter((dev: MediaDeviceInfo) => dev.kind === 'audiooutput');
};
export const AudioSettings = () => {
const settings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions();
const status = useCurrentStatus();
const [audioDevices, setAudioDevices] = useState<SelectItem[]>([]);
const [mpvPath, setMpvPath] = useState('');
const [mpvParameters, setMpvParameters] = useState('');
const handleSetMpvPath = (e: File) => {
localSettings.set('mpv_path', e.path);
};
useEffect(() => {
const getMpvPath = async () => {
if (!isElectron()) return setMpvPath('');
const mpvPath = (await localSettings.get('mpv_path')) as string;
return setMpvPath(mpvPath);
};
const getMpvParameters = async () => {
if (!isElectron()) return setMpvPath('');
const mpvParametersFromSettings = (await localSettings.get('mpv_parameters')) as string[];
const mpvParameters = mpvParametersFromSettings?.join('\n');
return setMpvParameters(mpvParameters);
};
getMpvPath();
getMpvParameters();
}, []);
useEffect(() => {
const getAudioDevices = () => {
getAudioDevice()
.then((dev) => setAudioDevices(dev.map((d) => ({ label: d.label, value: d.deviceId }))))
.catch(() => toast.error({ message: 'Error fetching audio devices' }));
};
getAudioDevices();
}, []);
const audioOptions: SettingOption[] = [
{
control: (
<Select
data={[
{
disabled: !isElectron(),
label: 'MPV',
value: PlaybackType.LOCAL,
},
{ label: 'Web', value: PlaybackType.WEB },
]}
defaultValue={settings.type}
disabled={status === PlayerStatus.PLAYING}
onChange={(e) => {
setSettings({ playback: { ...settings, type: e as PlaybackType } });
if (isElectron() && e === PlaybackType.LOCAL) {
const queueData = usePlayerStore.getState().actions.getPlayerData();
mpvPlayer.setQueue(queueData);
}
}}
/>
),
description: 'The audio player to use for playback',
isHidden: !isElectron(),
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Audio player',
},
{
control: (
<FileInput
placeholder={mpvPath}
width={225}
onChange={handleSetMpvPath}
/>
),
description: 'The location of your mpv executable',
isHidden: settings.type !== PlaybackType.LOCAL,
note: 'Restart required',
title: 'MPV executable path',
},
{
control: (
<Stack spacing="xs">
<Textarea
autosize
defaultValue={mpvParameters}
minRows={4}
placeholder={'(Add one per line):\n--gapless-audio=weak\n--prefetch-playlist=yes'}
width={225}
onBlur={(e) => {
if (isElectron()) {
localSettings.set('mpv_parameters', e.currentTarget.value.split('\n'));
}
}}
/>
</Stack>
),
description: (
<Stack spacing={0}>
<Text
$noSelect
$secondary
>
Options to pass to the player
</Text>
<Text>
<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: 'Restart required.',
title: 'MPV parameters',
},
{
control: (
<Select
clearable
data={audioDevices}
defaultValue={settings.audioDeviceId}
disabled={settings.type !== PlaybackType.WEB}
onChange={(e) => setSettings({ playback: { ...settings, audioDeviceId: e } })}
/>
),
description: 'The audio device to use for playback (web player only)',
isHidden: !isElectron() || settings.type !== PlaybackType.WEB,
title: 'Audio device',
},
{
control: (
<Select
data={[
{ label: 'Normal', value: PlaybackStyle.GAPLESS },
{ label: 'Crossfade', value: PlaybackStyle.CROSSFADE },
]}
defaultValue={settings.style}
disabled={settings.type !== PlaybackType.WEB || status === PlayerStatus.PLAYING}
onChange={(e) => setSettings({ playback: { ...settings, style: e as PlaybackStyle } })}
/>
),
description: 'Adjust the playback style (web player only)',
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Playback style',
},
{
control: (
<Slider
defaultValue={settings.crossfadeDuration}
disabled={
settings.type !== PlaybackType.WEB ||
settings.style !== PlaybackStyle.CROSSFADE ||
status === PlayerStatus.PLAYING
}
max={15}
min={0}
w={100}
onChangeEnd={(e) => setSettings({ playback: { ...settings, crossfadeDuration: e } })}
/>
),
description: 'Adjust the crossfade duration (web player only)',
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Crossfade Duration',
},
{
control: (
<Select
data={[
{ label: 'Linear', value: CrossfadeStyle.LINEAR },
{ label: 'Constant Power', value: CrossfadeStyle.CONSTANT_POWER },
{
label: 'Constant Power (Slow cut)',
value: CrossfadeStyle.CONSTANT_POWER_SLOW_CUT,
},
{
label: 'Constant Power (Slow fade)',
value: CrossfadeStyle.CONSTANT_POWER_SLOW_FADE,
},
{ label: 'Dipped', value: CrossfadeStyle.DIPPED },
{ label: 'Equal Power', value: CrossfadeStyle.EQUALPOWER },
]}
defaultValue={settings.crossfadeStyle}
disabled={
settings.type !== PlaybackType.WEB ||
settings.style !== PlaybackStyle.CROSSFADE ||
status === PlayerStatus.PLAYING
}
width={200}
onChange={(e) => {
if (!e) return;
setSettings({
playback: { ...settings, crossfadeStyle: e as CrossfadeStyle },
});
}}
/>
),
description: 'Change the crossfade algorithm (web player only)',
isHidden: settings.type !== PlaybackType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: 'Crossfade Style',
},
{
control: (
<Switch
aria-label="Toggle global media hotkeys"
defaultChecked={settings.globalMediaHotkeys}
disabled={!isElectron()}
onChange={(e) => {
setSettings({
playback: {
...settings,
globalMediaHotkeys: e.currentTarget.checked,
},
});
localSettings.set('global_media_hotkeys', e.currentTarget.checked);
if (e.currentTarget.checked) {
localSettings.enableMediaKeys();
} else {
localSettings.disableMediaKeys();
}
}}
/>
),
description:
'Enable or disable the usage of your system media hotkeys to control the audio player (desktop only)',
isHidden: !isElectron(),
title: 'Global media hotkeys',
},
];
return <SettingsSection options={audioOptions} />;
};

View file

@ -0,0 +1,99 @@
import isElectron from 'is-electron';
import { NumberInput, Slider, Switch, Text } from '/@/renderer/components';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { SettingOption, SettingsSection } from '../settings-section';
export const ScrobbleSettings = () => {
const settings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions();
const scrobbleOptions: SettingOption[] = [
{
control: (
<Switch
aria-label="Toggle scrobble"
defaultChecked={settings.scrobble.enabled}
onChange={(e) => {
setSettings({
playback: {
...settings,
scrobble: {
...settings.scrobble,
enabled: e.currentTarget.checked,
},
},
});
}}
/>
),
description: 'Enable or disable scrobbling to your media server',
isHidden: !isElectron(),
title: 'Scrobble',
},
{
control: (
<Slider
aria-label="Scrobble percentage"
defaultValue={settings.scrobble.scrobbleAtPercentage}
label={`${settings.scrobble.scrobbleAtPercentage}%`}
max={90}
min={25}
w={100}
onChange={(e) => {
setSettings({
playback: {
...settings,
scrobble: {
...settings.scrobble,
scrobbleAtPercentage: e,
},
},
});
}}
/>
),
description: 'The percentage of the song that must be played before submitting a scrobble',
isHidden: !isElectron(),
title: 'Minimum scrobble percentage*',
},
{
control: (
<NumberInput
aria-label="Scrobble duration in seconds"
defaultValue={settings.scrobble.scrobbleAtDuration}
max={1200}
min={0}
width={75}
onChange={(e) => {
if (e === '') return;
setSettings({
playback: {
...settings,
scrobble: {
...settings.scrobble,
scrobbleAtDuration: e,
},
},
});
}}
/>
),
description:
'The duration in seconds of a song that must be played before submitting a scrobble',
isHidden: !isElectron(),
title: 'Minimum scrobble duration (seconds)*',
},
];
return (
<>
<SettingsSection options={scrobbleOptions} />
<Text
$secondary
size="sm"
>
*The scrobble will be submitted if one or more of the above conditions is met
</Text>
</>
);
};

View file

@ -1,10 +1,10 @@
import { lazy } from 'react';
import { Box } from '@mantine/core';
import { Tabs } from '/@/renderer/components';
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import styled from 'styled-components';
const GeneralTab = lazy(() =>
import('/@/renderer/features/settings/components/general-tab').then((module) => ({
import('/@/renderer/features/settings/components/general/general-tab').then((module) => ({
default: module.GeneralTab,
})),
);
@ -15,16 +15,25 @@ const PlaybackTab = lazy(() =>
})),
);
const ApplicationTab = lazy(() =>
import('/@/renderer/features/settings/components/window/window-tab').then((module) => ({
default: module.WindowTab,
})),
);
const TabContainer = styled.div`
width: 100%;
height: 100%;
padding: 1rem;
overflow: scroll;
`;
export const SettingsContent = () => {
const currentTab = useSettingsStore((state) => state.tab);
const { setSettings } = useSettingsStoreActions();
return (
<Box
h="100%"
p="1rem"
sx={{ overflow: 'scroll' }}
>
<TabContainer>
<Tabs
keepMounted={false}
orientation="horizontal"
@ -35,6 +44,7 @@ export const SettingsContent = () => {
<Tabs.List>
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="playback">Playback</Tabs.Tab>
<Tabs.Tab value="window">Window</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">
<GeneralTab />
@ -42,7 +52,10 @@ export const SettingsContent = () => {
<Tabs.Panel value="playback">
<PlaybackTab />
</Tabs.Panel>
<Tabs.Panel value="window">
<ApplicationTab />
</Tabs.Panel>
</Tabs>
</Box>
</TabContainer>
);
};

View file

@ -0,0 +1,28 @@
import { SettingsOptions } from '/@/renderer/features/settings/components/settings-option';
export type SettingOption = {
control: JSX.Element;
description: string | JSX.Element;
isHidden?: boolean;
note?: string;
title: string;
};
interface SettingsSectionProps {
options: SettingOption[];
}
export const SettingsSection = ({ options }: SettingsSectionProps) => {
return (
<>
{options
.filter((o) => !o.isHidden)
.map((option) => (
<SettingsOptions
key={`general-${option.title}`}
{...option}
/>
))}
</>
);
};

View file

@ -0,0 +1,45 @@
import isElectron from 'is-electron';
import { Platform } from '/@/renderer/types';
import { useWindowSettings, useSettingsStoreActions } from '../../../../store/settings.store';
import {
SettingsSection,
SettingOption,
} from '/@/renderer/features/settings/components/settings-section';
import { Select } from '/@/renderer/components';
const WINDOW_BAR_OPTIONS = [
{ label: 'Web (hidden)', value: Platform.WEB },
{ label: 'Windows', value: Platform.WINDOWS },
{ label: 'macOS', value: Platform.MACOS },
];
export const WindowSettings = () => {
const settings = useWindowSettings();
const { setSettings } = useSettingsStoreActions();
const windowOptions: SettingOption[] = [
{
control: (
<Select
data={WINDOW_BAR_OPTIONS}
disabled={!isElectron()}
value={settings.windowBarStyle}
onChange={(e) => {
if (!e) return;
setSettings({
window: {
...settings,
windowBarStyle: e as Platform,
},
});
}}
/>
),
description: 'Adjust the style of the application window bar',
isHidden: !isElectron(),
title: 'Window bar style',
},
];
return <SettingsSection options={windowOptions} />;
};

View file

@ -0,0 +1,10 @@
import { Stack } from '@mantine/core';
import { WindowSettings } from './window-settings';
export const WindowTab = () => {
return (
<Stack spacing="md">
<WindowSettings />
</Stack>
);
};

View file

@ -1 +0,0 @@
export * from './components/settings';