crossfade player enhancements, reorganize settings

This commit is contained in:
jeffvli
2025-11-19 15:43:20 -08:00
parent 059f143dbf
commit 3aa93842a6
8 changed files with 546 additions and 398 deletions
@@ -17,7 +17,7 @@ import {
usePlayerProperties,
usePlayerVolume,
} from '/@/renderer/store';
import { PlayerStatus, PlayerStyle } from '/@/shared/types/types';
import { CrossfadeStyle, PlayerStatus, PlayerStyle } from '/@/shared/types/types';
const PLAY_PAUSE_FADE_DURATION = 300;
const PLAY_PAUSE_FADE_INTERVAL = 10;
@@ -26,7 +26,7 @@ export function WebPlayer() {
const playerRef = useRef<null | WebPlayerEngineHandle>(null);
const { num, player1, player2, status } = usePlayerData();
const { mediaAutoNext, setTimestamp } = usePlayerActions();
const { crossfadeDuration, speed, transitionType } = usePlayerProperties();
const { crossfadeDuration, crossfadeStyle, speed, transitionType } = usePlayerProperties();
const isMuted = usePlayerMuted();
const volume = usePlayerVolume();
@@ -79,6 +79,7 @@ export function WebPlayer() {
case PlayerStyle.CROSSFADE:
crossfadeHandler({
crossfadeDuration: crossfadeDuration,
crossfadeStyle,
currentPlayer: playerRef.current.player1(),
currentPlayerNum: num,
currentTime: e.playedSeconds,
@@ -102,7 +103,7 @@ export function WebPlayer() {
break;
}
},
[crossfadeDuration, isTransitioning, num, transitionType, volume],
[crossfadeDuration, crossfadeStyle, isTransitioning, num, transitionType, volume],
);
const onProgressPlayer2 = useCallback(
@@ -115,6 +116,7 @@ export function WebPlayer() {
case PlayerStyle.CROSSFADE:
crossfadeHandler({
crossfadeDuration: crossfadeDuration,
crossfadeStyle,
currentPlayer: playerRef.current.player2(),
currentPlayerNum: num,
currentTime: e.playedSeconds,
@@ -138,7 +140,7 @@ export function WebPlayer() {
break;
}
},
[crossfadeDuration, isTransitioning, num, transitionType, volume],
[crossfadeDuration, crossfadeStyle, isTransitioning, num, transitionType, volume],
);
const handleOnEndedPlayer1 = useCallback(() => {
@@ -171,6 +173,22 @@ export function WebPlayer() {
{
onPlayerSeekToTimestamp: (properties) => {
const timestamp = properties.timestamp;
// Reset transition state if seeking during a crossfade transition
if (isTransitioning && transitionType === PlayerStyle.CROSSFADE) {
setIsTransitioning(false);
if (num === 1) {
playerRef.current?.player1()?.setVolume(volume);
playerRef.current?.player2()?.setVolume(0);
playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();
} else {
playerRef.current?.player2()?.setVolume(volume);
playerRef.current?.player1()?.setVolume(0);
playerRef.current?.player1()?.ref?.getInternalPlayer()?.pause();
}
}
if (num === 1) {
playerRef.current?.player1()?.ref?.seekTo(timestamp);
} else {
@@ -179,6 +197,26 @@ export function WebPlayer() {
},
onPlayerStatus: async (properties) => {
const status = properties.status;
// Reset crossfade transition if paused during a crossfade transition
if (
status === PlayerStatus.PAUSED &&
isTransitioning &&
transitionType === PlayerStyle.CROSSFADE
) {
setIsTransitioning(false);
if (num === 1) {
playerRef.current?.player1()?.setVolume(volume);
playerRef.current?.player2()?.setVolume(0);
playerRef.current?.player2()?.ref?.getInternalPlayer()?.pause();
} else {
playerRef.current?.player2()?.setVolume(volume);
playerRef.current?.player1()?.setVolume(0);
playerRef.current?.player1()?.ref?.getInternalPlayer()?.pause();
}
}
if (status === PlayerStatus.PAUSED) {
fadeAndSetStatus(volume, 0, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PAUSED);
} else if (status === PlayerStatus.PLAYING) {
@@ -190,7 +228,7 @@ export function WebPlayer() {
playerRef.current?.setVolume(volume);
},
},
[volume, num, isTransitioning],
[volume, num, isTransitioning, transitionType],
);
useEffect(() => {
@@ -244,6 +282,7 @@ export function WebPlayer() {
function crossfadeHandler(args: {
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
currentPlayer: {
ref: null | ReactPlayer;
setVolume: (volume: number) => void;
@@ -262,6 +301,7 @@ function crossfadeHandler(args: {
}) {
const {
crossfadeDuration,
crossfadeStyle,
currentPlayer,
currentPlayerNum,
currentTime,
@@ -290,15 +330,57 @@ function crossfadeHandler(args: {
const timeLeft = duration - currentTime;
// Calculate the volume levels based on time remaining
const currentPlayerVolume = (timeLeft / crossfadeDuration) * volume;
const nextPlayerVolume = ((crossfadeDuration - timeLeft) / crossfadeDuration) * volume;
const progress = (crossfadeDuration - timeLeft) / crossfadeDuration;
const { easeIn, easeOut } = getCrossfadeEasing(crossfadeStyle);
const easedProgressOut = easeOut(progress);
const easedProgressIn = easeIn(progress);
const currentPlayerVolume = (1 - easedProgressOut) * volume;
const nextPlayerVolume = easedProgressIn * volume;
// Set volumes for both players
currentPlayer.setVolume(currentPlayerVolume);
nextPlayer.setVolume(nextPlayerVolume);
}
/**
* Equal power easing - maintains constant power during crossfade
* Fade in: sin(π/2 * t)
* Fade out: 1 - cos(π/2 * t) so that (1 - result) = cos(π/2 * t)
*/
function equalPowerEaseIn(t: number): number {
const clampedT = Math.max(0, Math.min(1, t));
return Math.sin((Math.PI / 2) * clampedT);
}
function equalPowerEaseOut(t: number): number {
const clampedT = Math.max(0, Math.min(1, t));
return 1 - Math.cos((Math.PI / 2) * clampedT);
}
/**
* Exponential easing - natural exponential decay/rise
* Fade in: 1 - exp(-k * t) where k controls the curve steepness
* Fade out: exp(-k * t) normalized to go from 1 to 0
*/
function exponentialEaseIn(t: number): number {
const clampedT = Math.max(0, Math.min(1, t));
const k = 5;
return 1 - Math.exp(-k * clampedT);
}
function exponentialEaseOut(t: number): number {
const clampedT = Math.max(0, Math.min(1, t));
const k = 5;
// Exponential decay: exp(-k * t) goes from 1 (at t=0) to exp(-k) (at t=1)
// Normalize to go from 1 to 0
const startValue = Math.exp(0); // = 1
const endValue = Math.exp(-k);
return (Math.exp(-k * clampedT) - endValue) / (startValue - endValue);
}
function gaplessHandler(args: {
currentTime: number;
duration: number;
@@ -332,6 +414,40 @@ function gaplessHandler(args: {
return null;
}
function getCrossfadeEasing(style: CrossfadeStyle): {
easeIn: (t: number) => number;
easeOut: (t: number) => number;
} {
switch (style) {
case CrossfadeStyle.EQUAL_POWER:
return {
easeIn: equalPowerEaseIn,
easeOut: equalPowerEaseOut,
};
case CrossfadeStyle.EXPONENTIAL:
return {
easeIn: exponentialEaseIn,
easeOut: exponentialEaseOut,
};
case CrossfadeStyle.LINEAR:
return {
easeIn: linearEase,
easeOut: linearEase,
};
case CrossfadeStyle.S_CURVE:
return {
easeIn: sCurveEase,
easeOut: sCurveEase,
};
// Default to equal power for other styles
default:
return {
easeIn: equalPowerEaseIn,
easeOut: equalPowerEaseOut,
};
}
}
function getDuration(ref: null | ReactPlayer | undefined) {
return ref?.getInternalPlayer()?.duration || 0;
}
@@ -344,3 +460,19 @@ function getDurationPadding(isFlac: boolean) {
return 0.065;
}
}
/**
* Linear easing - simple linear interpolation
*/
function linearEase(t: number): number {
return Math.max(0, Math.min(1, t));
}
/**
* S-Curve easing (smoothstep) - smooth S-shaped curve
* Uses smoothstep function: t²(3 - 2t)
*/
function sCurveEase(t: number): number {
const clampedT = Math.max(0, Math.min(1, t));
return clampedT * clampedT * (3 - 2 * clampedT);
}
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import isElectron from 'is-electron';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListConfigTable } from '/@/renderer/features/shared/components/list-config-menu';
@@ -8,35 +9,61 @@ import {
usePlayerProperties,
usePlayerQueueType,
usePlayerSpeed,
usePlayerStatus,
} from '/@/renderer/store';
import {
BarAlign,
PlayerbarSliderType,
useGeneralSettings,
usePlaybackSettings,
usePlayerbarSlider,
useSettingsStore,
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Popover } from '/@/shared/components/popover/popover';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Slider } from '/@/shared/components/slider/slider';
import { Switch } from '/@/shared/components/switch/switch';
import { PlayerQueueType, PlayerStyle, PlayerType } from '/@/shared/types/types';
import { toast } from '/@/shared/components/toast/toast';
import {
CrossfadeStyle,
PlayerQueueType,
PlayerStatus,
PlayerStyle,
PlayerType,
} from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null;
const getAudioDevice = async () => {
const devices = await navigator.mediaDevices.enumerateDevices();
return (devices || []).filter((dev: MediaDeviceInfo) => dev.kind === 'audiooutput');
};
export const PlayerConfig = () => {
const { t } = useTranslation();
const { currentSong } = usePlayerData();
const speed = usePlayerSpeed();
const queueType = usePlayerQueueType();
const { crossfadeDuration, transitionType } = usePlayerProperties();
const { setCrossfadeDuration, setQueueType, setSpeed, setTransitionType } = usePlayerActions();
const status = usePlayerStatus();
const { crossfadeDuration, crossfadeStyle, transitionType } = usePlayerProperties();
const { setCrossfadeDuration, setCrossfadeStyle, setQueueType, setSpeed, setTransitionType } =
usePlayerActions();
const playbackSettings = usePlaybackSettings();
const { setSettings } = useSettingsStoreActions();
const speedPreservePitch = useSettingsStore((state) => state.playback.preservePitch);
const playerbarSlider = usePlayerbarSlider();
const generalSettings = useGeneralSettings();
const [audioDevices, setAudioDevices] = useState<{ label: string; value: string }[]>([]);
useEffect(() => {
const fetchAudioDevices = () => {
getAudioDevice()
.then((dev) =>
setAudioDevices(dev.map((d) => ({ label: d.label, value: d.deviceId }))),
)
.catch(() =>
toast.error({
message: t('error.audioDeviceFetchError', { postProcess: 'sentenceCase' }),
}),
);
};
if (playbackSettings.type === PlayerType.WEB) {
fetchAudioDevices();
}
}, [playbackSettings.type, t]);
const options = useMemo(() => {
const formatPlaybackSpeedSliderLabel = (value: number) => {
@@ -70,250 +97,163 @@ export const PlayerConfig = () => {
id: 'queueType',
label: t('player.queueType', { postProcess: 'titleCase' }),
},
{
component: null,
id: 'divider-0',
isDivider: true,
label: '',
},
{
component: (
<Select
comboboxProps={{ withinPortal: false }}
data={[
{
disabled: !isElectron(),
label: 'MPV',
value: PlayerType.LOCAL,
},
{ label: 'Web', value: PlayerType.WEB },
]}
defaultValue={playbackSettings.type}
disabled={status === PlayerStatus.PLAYING}
onChange={(e) => {
setSettings({
playback: { ...playbackSettings, type: e as PlayerType },
});
ipc?.send('settings-set', {
property: 'playbackType',
value: e,
});
}}
width="100%"
/>
),
id: 'audioPlayerType',
label: t('setting.audioPlayer', { postProcess: 'titleCase' }),
},
{
component: (
<Select
clearable
comboboxProps={{ withinPortal: false }}
data={audioDevices}
defaultValue={playbackSettings.audioDeviceId}
disabled={playbackSettings.type !== PlayerType.WEB}
onChange={(e) =>
setSettings({
playback: {
...playbackSettings,
audioDeviceId: e,
},
})
}
width="100%"
/>
),
id: 'audioDevice',
label: t('setting.audioDevice', { postProcess: 'titleCase' }),
},
{
component: null,
id: 'divider-1',
isDivider: true,
label: '',
},
...(playbackSettings.type === PlayerType.WEB
? [
{
component: (
<SegmentedControl
data={[
{
label: t('setting.playbackStyle', {
context: 'optionNormal',
postProcess: 'titleCase',
}),
value: PlayerStyle.GAPLESS,
},
{
label: t('setting.playbackStyle', {
context: 'optionCrossFade',
postProcess: 'titleCase',
}),
value: PlayerStyle.CROSSFADE,
},
]}
onChange={(value) => setTransitionType(value as PlayerStyle)}
size="sm"
value={transitionType}
w="100%"
/>
),
id: 'transitionType',
label: t('setting.playbackStyle', {
postProcess: 'titleCase',
}),
},
]
: []),
...(playbackSettings.type === PlayerType.WEB && transitionType === PlayerStyle.CROSSFADE
? [
{
component: (
<Slider
defaultValue={crossfadeDuration}
marks={[
{ label: '3', value: 3 },
{ label: '6', value: 6 },
{ label: '9', value: 9 },
{ label: '12', value: 12 },
{ label: '15', value: 15 },
]}
max={15}
min={3}
onChangeEnd={setCrossfadeDuration}
styles={{
root: {},
}}
w="100%"
/>
),
id: 'crossfadeDuration',
label: t('setting.crossfadeDuration', {
postProcess: 'titleCase',
}),
},
]
: []),
{
component: (
<SegmentedControl
data={[
{
label: t('setting.playerbarSliderType', {
context: 'optionSlider',
label: t('setting.playbackStyle', {
context: 'optionNormal',
postProcess: 'titleCase',
}),
value: PlayerbarSliderType.SLIDER,
value: PlayerStyle.GAPLESS,
},
{
label: t('setting.playerbarSliderType', {
context: 'optionWaveform',
label: t('setting.playbackStyle', {
context: 'optionCrossFade',
postProcess: 'titleCase',
}),
value: PlayerbarSliderType.WAVEFORM,
value: PlayerStyle.CROSSFADE,
},
]}
onChange={(value) => {
setSettings({
general: {
...generalSettings,
playerbarSlider: {
...playerbarSlider,
type: value as PlayerbarSliderType,
},
},
});
}}
disabled={
!isElectron() ||
playbackSettings.type !== PlayerType.WEB ||
status === PlayerStatus.PLAYING
}
onChange={(value) => setTransitionType(value as PlayerStyle)}
size="sm"
value={playerbarSlider?.type || PlayerbarSliderType.WAVEFORM}
value={transitionType}
w="100%"
/>
),
id: 'playerbarSliderType',
label: t('setting.playerbarSlider', { postProcess: 'titleCase' }),
id: 'transitionType',
label: t('setting.playbackStyle', {
postProcess: 'titleCase',
}),
},
{
component: (
<Select
comboboxProps={{ withinPortal: false }}
data={[
{ label: 'Linear', value: CrossfadeStyle.LINEAR },
{ label: 'Equal Power', value: CrossfadeStyle.EQUAL_POWER },
{ label: 'S-Curve', value: CrossfadeStyle.S_CURVE },
{ label: 'Exponential', value: CrossfadeStyle.EXPONENTIAL },
]}
defaultValue={crossfadeStyle}
disabled={
playbackSettings.type !== PlayerType.WEB ||
transitionType !== PlayerStyle.CROSSFADE ||
status === PlayerStatus.PLAYING
}
onChange={(e) => {
if (e) {
setCrossfadeStyle(e as CrossfadeStyle);
}
}}
width="100%"
/>
),
id: 'crossfadeStyle',
label: t('setting.crossfadeStyle', {
postProcess: 'titleCase',
}),
},
{
component: (
<Slider
defaultValue={crossfadeDuration}
disabled={
playbackSettings.type !== PlayerType.WEB ||
transitionType !== PlayerStyle.CROSSFADE ||
status === PlayerStatus.PLAYING
}
marks={[
{ label: '3', value: 3 },
{ label: '6', value: 6 },
{ label: '9', value: 9 },
{ label: '12', value: 12 },
{ label: '15', value: 15 },
]}
max={15}
min={3}
onChangeEnd={setCrossfadeDuration}
styles={{
root: {},
}}
w="100%"
/>
),
id: 'crossfadeDuration',
label: t('setting.crossfadeDuration', {
postProcess: 'titleCase',
}),
},
...(playerbarSlider?.type === PlayerbarSliderType.WAVEFORM
? [
{
component: (
<SegmentedControl
data={[
{
label: t('setting.playerbarWaveformAlign', {
context: 'optionTop',
postProcess: 'titleCase',
}),
value: BarAlign.TOP,
},
{
label: t('setting.playerbarWaveformAlign', {
context: 'optionCenter',
postProcess: 'titleCase',
}),
value: BarAlign.CENTER,
},
{
label: t('setting.playerbarWaveformAlign', {
context: 'optionBottom',
postProcess: 'titleCase',
}),
value: BarAlign.BOTTOM,
},
]}
onChange={(value) => {
setSettings({
general: {
...generalSettings,
playerbarSlider: {
...playerbarSlider,
barAlign: (value as BarAlign) || BarAlign.CENTER,
},
},
});
}}
size="sm"
value={playerbarSlider?.barAlign || BarAlign.CENTER}
w="100%"
/>
),
id: 'barAlign',
label: t('setting.playerbarWaveformAlign', {
postProcess: 'titleCase',
}),
},
{
component: (
<Slider
defaultValue={playerbarSlider?.barWidth ?? 2}
max={10}
min={0}
onChangeEnd={(value) => {
setSettings({
general: {
...generalSettings,
playerbarSlider: {
...playerbarSlider,
barWidth: value,
},
},
});
}}
step={1}
styles={{
root: {},
}}
w="100%"
/>
),
id: 'barWidth',
label: t('setting.playerbarWaveformBarWidth', {
postProcess: 'titleCase',
}),
},
{
component: (
<Slider
defaultValue={playerbarSlider?.barGap || 0}
max={10}
min={0}
onChangeEnd={(value) => {
setSettings({
general: {
...generalSettings,
playerbarSlider: {
...playerbarSlider,
barGap: value,
},
},
});
}}
step={1}
styles={{
root: {},
}}
w="100%"
/>
),
id: 'barGap',
label: t('setting.playerbarWaveformGap', { postProcess: 'titleCase' }),
},
{
component: (
<Slider
defaultValue={playerbarSlider?.barRadius ?? 4}
max={20}
min={0}
onChangeEnd={(value) => {
setSettings({
general: {
...generalSettings,
playerbarSlider: {
...playerbarSlider,
barRadius: value,
},
},
});
}}
step={1}
styles={{
root: {},
}}
w="100%"
/>
),
id: 'barRadius',
label: t('setting.playerbarWaveformRadius', {
postProcess: 'titleCase',
}),
},
]
: []),
{
component: null,
id: 'divider-2',
@@ -323,6 +263,7 @@ export const PlayerConfig = () => {
{
component: (
<Slider
defaultValue={speed}
label={formatPlaybackSpeedSliderLabel}
marks={[
{ label: '0.5', value: 0.5 },
@@ -335,49 +276,26 @@ export const PlayerConfig = () => {
]}
max={2}
min={0.5}
onChange={setSpeed}
onChangeEnd={setSpeed}
onDoubleClick={() => setSpeed(1)}
step={0.01}
styles={{
markLabel: {},
root: {},
}}
value={speed}
w="100%"
/>
),
id: 'playbackSpeed',
label: t('player.playbackSpeed', { postProcess: 'titleCase' }),
},
...(speed !== 1
? [
{
component: (
<Switch
defaultChecked={speedPreservePitch}
onChange={(e) => {
setSettings({
playback: {
...playbackSettings,
preservePitch: e.currentTarget.checked,
},
});
}}
/>
),
id: 'preservePitch',
label: t('setting.preservePitch', {
postProcess: 'titleCase',
}),
},
]
: []),
];
return allOptions;
}, [
playbackSettings,
speedPreservePitch,
audioDevices,
status,
setSettings,
currentSong,
speed,
@@ -388,8 +306,8 @@ export const PlayerConfig = () => {
setTransitionType,
crossfadeDuration,
setCrossfadeDuration,
playerbarSlider,
generalSettings,
crossfadeStyle,
setCrossfadeStyle,
t,
]);
@@ -7,13 +7,17 @@ import {
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import {
BarAlign,
GenreTarget,
PlayerbarSliderType,
SideQueueType,
useGeneralSettings,
usePlayerbarSlider,
useSettingsStoreActions,
} from '/@/renderer/store/settings.store';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Slider } from '/@/shared/components/slider/slider';
import { Switch } from '/@/shared/components/switch/switch';
@@ -43,6 +47,7 @@ const SIDE_QUEUE_OPTIONS = [
export const ControlSettings = () => {
const { t } = useTranslation();
const settings = useGeneralSettings();
const playerbarSlider = usePlayerbarSlider();
const { setSettings } = useSettingsStoreActions();
const controlOptions: SettingOption[] = [
@@ -621,6 +626,202 @@ export const ControlSettings = () => {
isHidden: false,
title: t('setting.playerbarOpenDrawer', { postProcess: 'sentenceCase' }),
},
{
control: (
<SegmentedControl
data={[
{
label: t('setting.playerbarSliderType', {
context: 'optionSlider',
postProcess: 'titleCase',
}),
value: PlayerbarSliderType.SLIDER,
},
{
label: t('setting.playerbarSliderType', {
context: 'optionWaveform',
postProcess: 'titleCase',
}),
value: PlayerbarSliderType.WAVEFORM,
},
]}
onChange={(value) => {
setSettings({
general: {
...settings,
playerbarSlider: {
...playerbarSlider,
type: value as PlayerbarSliderType,
},
},
});
}}
size="sm"
value={playerbarSlider?.type || PlayerbarSliderType.WAVEFORM}
w="100%"
/>
),
description: t('setting.playerbarSlider', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.playerbarSlider', { postProcess: 'sentenceCase' }),
},
...(playerbarSlider?.type === PlayerbarSliderType.WAVEFORM
? [
{
control: (
<SegmentedControl
data={[
{
label: t('setting.playerbarWaveformAlign', {
context: 'optionTop',
postProcess: 'titleCase',
}),
value: BarAlign.TOP,
},
{
label: t('setting.playerbarWaveformAlign', {
context: 'optionCenter',
postProcess: 'titleCase',
}),
value: BarAlign.CENTER,
},
{
label: t('setting.playerbarWaveformAlign', {
context: 'optionBottom',
postProcess: 'titleCase',
}),
value: BarAlign.BOTTOM,
},
]}
onChange={(value) => {
setSettings({
general: {
...settings,
playerbarSlider: {
...playerbarSlider,
barAlign: (value as BarAlign) || BarAlign.CENTER,
},
},
});
}}
size="sm"
value={playerbarSlider?.barAlign || BarAlign.CENTER}
w="100%"
/>
),
description: t('setting.playerbarWaveformAlign', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.playerbarWaveformAlign', {
postProcess: 'sentenceCase',
}),
},
{
control: (
<Slider
defaultValue={playerbarSlider?.barWidth ?? 2}
max={10}
min={0}
onChangeEnd={(value) => {
setSettings({
general: {
...settings,
playerbarSlider: {
...playerbarSlider,
barWidth: value,
},
},
});
}}
step={1}
styles={{
root: {},
}}
w="120px"
/>
),
description: t('setting.playerbarWaveformBarWidth', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.playerbarWaveformBarWidth', {
postProcess: 'sentenceCase',
}),
},
{
control: (
<Slider
defaultValue={playerbarSlider?.barGap || 0}
max={10}
min={0}
onChangeEnd={(value) => {
setSettings({
general: {
...settings,
playerbarSlider: {
...playerbarSlider,
barGap: value,
},
},
});
}}
step={1}
styles={{
root: {},
}}
w="120px"
/>
),
description: t('setting.playerbarWaveformGap', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.playerbarWaveformGap', {
postProcess: 'sentenceCase',
}),
},
{
control: (
<Slider
defaultValue={playerbarSlider?.barRadius ?? 4}
max={20}
min={0}
onChangeEnd={(value) => {
setSettings({
general: {
...settings,
playerbarSlider: {
...playerbarSlider,
barRadius: value,
},
},
});
}}
step={1}
styles={{
root: {},
}}
w="120px"
/>
),
description: t('setting.playerbarWaveformRadius', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: false,
title: t('setting.playerbarWaveformRadius', {
postProcess: 'sentenceCase',
}),
},
]
: []),
];
return <SettingsSection options={controlOptions} />;
@@ -6,13 +6,12 @@ import {
SettingOption,
SettingsSection,
} from '/@/renderer/features/settings/components/settings-section';
import { usePlayerActions, usePlayerProperties, usePlayerStatus } from '/@/renderer/store';
import { usePlayerStatus } from '/@/renderer/store';
import { usePlaybackSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store';
import { Select } from '/@/shared/components/select/select';
import { Slider } from '/@/shared/components/slider/slider';
import { Switch } from '/@/shared/components/switch/switch';
import { toast } from '/@/shared/components/toast/toast';
import { CrossfadeStyle, PlayerStatus, PlayerStyle, PlayerType } from '/@/shared/types/types';
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
const ipc = isElectron() ? window.api.ipc : null;
@@ -27,9 +26,6 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
const { setSettings } = useSettingsStoreActions();
const status = usePlayerStatus();
const { crossfadeDuration, transitionType } = usePlayerProperties();
const { setCrossfadeDuration, setTransitionType } = usePlayerActions();
const [audioDevices, setAudioDevices] = useState<{ label: string; value: string }[]>([]);
useEffect(() => {
@@ -98,41 +94,6 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
isHidden: !isElectron() || settings.type !== PlayerType.WEB,
title: t('setting.audioDevice', { postProcess: 'sentenceCase' }),
},
{
control: (
<Select
data={[
{
label: t('setting.playbackStyle', {
context: 'optionNormal',
postProcess: 'titleCase',
}),
value: PlayerStyle.GAPLESS,
},
{
label: t('setting.playbackStyle', {
context: 'optionCrossFade',
postProcess: 'titleCase',
}),
value: PlayerStyle.CROSSFADE,
},
]}
defaultValue={transitionType}
disabled={settings.type !== PlayerType.WEB || status === PlayerStatus.PLAYING}
onChange={(e) => setTransitionType(e as PlayerStyle)}
/>
),
description: t('setting.playbackStyle', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlayerType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: t('setting.playbackStyle', {
context: 'description',
postProcess: 'sentenceCase',
}),
},
{
control: (
<Switch
@@ -174,71 +135,6 @@ export const AudioSettings = ({ hasFancyAudio }: { hasFancyAudio: boolean }) =>
postProcess: 'sentenceCase',
}),
},
{
control: (
<Slider
defaultValue={crossfadeDuration}
disabled={
settings.type !== PlayerType.WEB ||
settings.style !== PlayerStyle.CROSSFADE ||
status === PlayerStatus.PLAYING
}
max={15}
min={3}
onChangeEnd={(e) => setCrossfadeDuration(e)}
w={100}
/>
),
description: t('setting.crossfadeDuration', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlayerType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: t('setting.crossfadeDuration', {
postProcess: 'sentenceCase',
}),
},
{
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 !== PlayerType.WEB ||
settings.style !== PlayerStyle.CROSSFADE ||
status === PlayerStatus.PLAYING
}
onChange={(e) => {
if (!e) return;
setSettings({
playback: { ...settings, crossfadeStyle: e as CrossfadeStyle },
});
}}
width={200}
/>
),
description: t('setting.crossfadeStyle', {
context: 'description',
postProcess: 'sentenceCase',
}),
isHidden: settings.type !== PlayerType.WEB,
note: status === PlayerStatus.PLAYING ? 'Player must be paused' : undefined,
title: t('setting.crossfadeStyle', { postProcess: 'sentenceCase' }),
},
];
return <SettingsSection divider={!hasFancyAudio} options={audioOptions} />;
+11
View File
@@ -15,6 +15,7 @@ import { idbStateStorage } from '/@/renderer/store/utils';
import { shuffleInPlace } from '/@/renderer/utils/shuffle';
import { PlayerData, QueueData, QueueSong, Song } from '/@/shared/types/domain-types';
import {
CrossfadeStyle,
Play,
PlayerQueueType,
PlayerRepeat,
@@ -56,6 +57,7 @@ interface Actions {
moveSelectedToNext: (items: QueueSong[]) => void;
moveSelectedToTop: (items: QueueSong[]) => void;
setCrossfadeDuration: (duration: number) => void;
setCrossfadeStyle: (style: CrossfadeStyle) => void;
setQueueType: (queueType: PlayerQueueType) => void;
setRepeat: (repeat: PlayerRepeat) => void;
setShuffle: (shuffle: PlayerShuffle) => void;
@@ -77,6 +79,7 @@ interface GroupedQueue {
interface State {
player: {
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
index: number;
muted: boolean;
playerNum: 1 | 2;
@@ -95,6 +98,7 @@ interface State {
const initialState: State = {
player: {
crossfadeDuration: 5,
crossfadeStyle: CrossfadeStyle.EQUAL_POWER,
index: -1,
muted: false,
playerNum: 1,
@@ -1048,6 +1052,11 @@ export const usePlayerStoreBase = create<PlayerState>()(
state.player.crossfadeDuration = normalizedDuration;
});
},
setCrossfadeStyle: (style: CrossfadeStyle) => {
set((state) => {
state.player.crossfadeStyle = style;
});
},
setQueueType: (queueType: PlayerQueueType) => {
set((state) => {
// From default -> priority, move all items from default to priority
@@ -1245,6 +1254,7 @@ export const usePlayerActions = () => {
moveSelectedToNext: state.moveSelectedToNext,
moveSelectedToTop: state.moveSelectedToTop,
setCrossfadeDuration: state.setCrossfadeDuration,
setCrossfadeStyle: state.setCrossfadeStyle,
setQueueType: state.setQueueType,
setRepeat: state.setRepeat,
setShuffle: state.setShuffle,
@@ -1403,6 +1413,7 @@ export const usePlayerProperties = () => {
return usePlayerStoreBase(
useShallow((state) => ({
crossfadeDuration: state.player.crossfadeDuration,
crossfadeStyle: state.player.crossfadeStyle,
isMuted: state.player.muted,
playerNum: state.player.playerNum,
queueType: state.player.queueType,
+1 -14
View File
@@ -25,14 +25,12 @@ import { sanitizeCss } from '/@/renderer/utils/sanitize';
import { AppTheme } from '/@/shared/themes/app-theme-types';
import { LibraryItem, LyricSource } from '/@/shared/types/domain-types';
import {
CrossfadeStyle,
FontType,
ItemListKey,
ListDisplayType,
ListPaginationType,
Platform,
Play,
PlayerStyle,
PlayerType,
TableColumn,
} from '/@/shared/types/types';
@@ -306,15 +304,11 @@ const ScrobbleSettingsSchema = z.object({
const PlaybackSettingsSchema = z.object({
audioDeviceId: z.string().nullable().optional(),
crossfadeDuration: z.number(),
crossfadeStyle: z.nativeEnum(CrossfadeStyle),
mediaSession: z.boolean(),
mpvExtraParameters: z.array(z.string()),
mpvProperties: MpvSettingsSchema,
muted: z.boolean(),
preservePitch: z.boolean(),
scrobble: ScrobbleSettingsSchema,
style: z.nativeEnum(PlayerStyle),
transcode: TranscodingConfigSchema,
type: z.nativeEnum(PlayerType),
webAudio: z.boolean(),
@@ -1090,8 +1084,6 @@ const initialState: SettingsState = {
},
playback: {
audioDeviceId: undefined,
crossfadeDuration: 5,
crossfadeStyle: CrossfadeStyle.EQUALPOWER,
mediaSession: false,
mpvExtraParameters: [],
mpvProperties: {
@@ -1104,7 +1096,6 @@ const initialState: SettingsState = {
replayGainMode: 'no',
replayGainPreampDB: 0,
},
muted: false,
preservePitch: true,
scrobble: {
enabled: true,
@@ -1112,7 +1103,6 @@ const initialState: SettingsState = {
scrobbleAtDuration: 240,
scrobbleAtPercentage: 75,
},
style: PlayerStyle.GAPLESS,
transcode: {
enabled: false,
},
@@ -1289,10 +1279,7 @@ export const useTableSettings = (type: ItemListKey) =>
export const useGeneralSettings = () => useSettingsStore((state) => state.general, shallow);
export const usePlaybackType = () =>
useSettingsStore((state) => {
return state.playback.type;
});
export const usePlaybackType = () => useSettingsStore((state) => state.playback.type, shallow);
export const usePlayButtonBehavior = () =>
useSettingsStore((state) => state.general.playButtonBehavior, shallow);
+5
View File
@@ -11,7 +11,9 @@ export interface SelectProps extends MantineSelectProps {
}
export const Select = ({
allowDeselect = false,
classNames,
clearable = false,
maxWidth,
variant = 'default',
width,
@@ -19,14 +21,17 @@ export const Select = ({
}: SelectProps) => {
return (
<MantineSelect
allowDeselect={allowDeselect || clearable}
classNames={{
dropdown: styles.dropdown,
input: styles.input,
label: styles.label,
option: styles.option,
root: styles.root,
section: styles.section,
...classNames,
}}
clearable={false}
style={{ maxWidth, width }}
variant={variant}
withCheckIcon={false}
+3 -5
View File
@@ -95,12 +95,10 @@ export enum AuthState {
}
export enum CrossfadeStyle {
CONSTANT_POWER = 'constantPower',
CONSTANT_POWER_SLOW_CUT = 'constantPowerSlowCut',
CONSTANT_POWER_SLOW_FADE = 'constantPowerSlowFade',
DIPPED = 'dipped',
EQUALPOWER = 'equalPower',
EQUAL_POWER = 'equalPower',
EXPONENTIAL = 'exponential',
LINEAR = 'linear',
S_CURVE = 'sCurve',
}
export enum FontType {