From 0dff13c43f49bc8df1cb1f1b9696aaf67a6c218b Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 19 Nov 2025 15:43:20 -0800 Subject: [PATCH] crossfade player enhancements, reorganize settings --- .../player/audio-player/web-player.tsx | 148 +++++- .../player/components/player-config.tsx | 448 +++++++----------- .../components/general/control-settings.tsx | 201 ++++++++ .../components/playback/audio-settings.tsx | 108 +---- src/renderer/store/player.store.ts | 11 + src/renderer/store/settings.store.ts | 15 +- src/shared/components/select/select.tsx | 5 + src/shared/types/types.ts | 8 +- 8 files changed, 546 insertions(+), 398 deletions(-) diff --git a/src/renderer/features/player/audio-player/web-player.tsx b/src/renderer/features/player/audio-player/web-player.tsx index 70d89fef3..831821347 100644 --- a/src/renderer/features/player/audio-player/web-player.tsx +++ b/src/renderer/features/player/audio-player/web-player.tsx @@ -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); 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); +} diff --git a/src/renderer/features/player/components/player-config.tsx b/src/renderer/features/player/components/player-config.tsx index 96bc148f2..93e8e780a 100644 --- a/src/renderer/features/player/components/player-config.tsx +++ b/src/renderer/features/player/components/player-config.tsx @@ -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: ( + + 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: ( - 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: ( - - ), - id: 'crossfadeDuration', - label: t('setting.crossfadeDuration', { - postProcess: 'titleCase', - }), - }, - ] - : []), { component: ( { - 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: ( + 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: ( postProcess: 'sentenceCase', }), }, - { - control: ( - 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: ( -