import type { Dispatch } from 'react'; import type ReactPlayer from 'react-player'; import { useCallback, useEffect, useRef, useState } from 'react'; import { WebPlayerEngine, WebPlayerEngineHandle, } from '/@/renderer/features/player/audio-player/engine/web-player-engine'; import { useMainPlayerListener } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types'; import { usePlayerActions, usePlayerData, usePlayerMuted, usePlayerProperties, usePlayerVolume, } from '/@/renderer/store'; import { CrossfadeStyle, PlayerStatus, PlayerStyle } from '/@/shared/types/types'; const PLAY_PAUSE_FADE_DURATION = 300; const PLAY_PAUSE_FADE_INTERVAL = 10; export function WebPlayer() { const playerRef = useRef(null); const { num, player1, player2, status } = usePlayerData(); const { mediaAutoNext, setTimestamp } = usePlayerActions(); const { crossfadeDuration, crossfadeStyle, speed, transitionType } = usePlayerProperties(); const isMuted = usePlayerMuted(); const volume = usePlayerVolume(); const [localPlayerStatus, setLocalPlayerStatus] = useState(status); const [isTransitioning, setIsTransitioning] = useState(false); const fadeAndSetStatus = useCallback( async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => { if (isTransitioning) { return setLocalPlayerStatus(status); } const steps = duration / PLAY_PAUSE_FADE_INTERVAL; const volumeStep = (endVolume - startVolume) / steps; let currentStep = 0; const promise = new Promise((resolve) => { const interval = setInterval(() => { currentStep++; const newVolume = startVolume + volumeStep * currentStep; playerRef.current?.setVolume(newVolume); if (currentStep >= steps) { clearInterval(interval); setIsTransitioning(false); resolve(true); } }, PLAY_PAUSE_FADE_INTERVAL); }); if (status === PlayerStatus.PAUSED) { await promise; setLocalPlayerStatus(status); } else if (status === PlayerStatus.PLAYING) { setLocalPlayerStatus(status); await promise; } }, [isTransitioning], ); const onProgressPlayer1 = useCallback( (e: PlayerOnProgressProps) => { if (!playerRef.current?.player1()) { return; } switch (transitionType) { case PlayerStyle.CROSSFADE: crossfadeHandler({ crossfadeDuration: crossfadeDuration, crossfadeStyle, currentPlayer: playerRef.current.player1(), currentPlayerNum: num, currentTime: e.playedSeconds, duration: getDuration(playerRef.current.player1().ref), isTransitioning, nextPlayer: playerRef.current.player2(), playerNum: 1, setIsTransitioning, volume, }); break; case PlayerStyle.GAPLESS: gaplessHandler({ currentTime: e.playedSeconds, duration: getDuration(playerRef.current.player1().ref), isFlac: false, isTransitioning, nextPlayer: playerRef.current.player2(), setIsTransitioning, }); break; } }, [crossfadeDuration, crossfadeStyle, isTransitioning, num, transitionType, volume], ); const onProgressPlayer2 = useCallback( (e: PlayerOnProgressProps) => { if (!playerRef.current?.player2()) { return; } switch (transitionType) { case PlayerStyle.CROSSFADE: crossfadeHandler({ crossfadeDuration: crossfadeDuration, crossfadeStyle, currentPlayer: playerRef.current.player2(), currentPlayerNum: num, currentTime: e.playedSeconds, duration: getDuration(playerRef.current.player2().ref), isTransitioning, nextPlayer: playerRef.current.player1(), playerNum: 2, setIsTransitioning, volume, }); break; case PlayerStyle.GAPLESS: gaplessHandler({ currentTime: e.playedSeconds, duration: getDuration(playerRef.current.player2().ref), isFlac: false, isTransitioning, nextPlayer: playerRef.current.player1(), setIsTransitioning, }); break; } }, [crossfadeDuration, crossfadeStyle, isTransitioning, num, transitionType, volume], ); const handleOnEndedPlayer1 = useCallback(() => { const promise = new Promise((resolve) => { mediaAutoNext(); resolve(true); }); promise.then(() => { playerRef.current?.player1()?.ref?.getInternalPlayer().pause(); playerRef.current?.setVolume(volume); setIsTransitioning(false); }); }, [mediaAutoNext, volume]); const handleOnEndedPlayer2 = useCallback(() => { const promise = new Promise((resolve) => { mediaAutoNext(); resolve(true); }); promise.then(() => { playerRef.current?.player2()?.ref?.getInternalPlayer().pause(); playerRef.current?.setVolume(volume); setIsTransitioning(false); }); }, [mediaAutoNext, volume]); usePlayerEvents( { 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 { playerRef.current?.player2()?.ref?.seekTo(timestamp); } }, 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) { fadeAndSetStatus(0, volume, PLAY_PAUSE_FADE_DURATION, PlayerStatus.PLAYING); } }, onPlayerVolume: (properties) => { const volume = properties.volume; playerRef.current?.setVolume(volume); }, }, [volume, num, isTransitioning, transitionType], ); useEffect(() => { if (localPlayerStatus !== PlayerStatus.PLAYING) { return; } const interval = setInterval(() => { const activePlayer = num === 1 ? playerRef.current?.player1() : playerRef.current?.player2(); const internalPlayer = activePlayer?.ref?.getInternalPlayer() as HTMLAudioElement | null; if (!internalPlayer) { return; } const currentTime = internalPlayer.currentTime; if ( transitionType === PlayerStyle.CROSSFADE || transitionType === PlayerStyle.GAPLESS ) { setTimestamp(Number(currentTime.toFixed(0))); } }, 500); return () => clearInterval(interval); }, [localPlayerStatus, num, setTimestamp, transitionType]); useMainPlayerListener(); return ( ); } function crossfadeHandler(args: { crossfadeDuration: number; crossfadeStyle: CrossfadeStyle; currentPlayer: { ref: null | ReactPlayer; setVolume: (volume: number) => void; }; currentPlayerNum: number; currentTime: number; duration: number; isTransitioning: boolean | string; nextPlayer: { ref: null | ReactPlayer; setVolume: (volume: number) => void; }; playerNum: number; setIsTransitioning: Dispatch; volume: number; }) { const { crossfadeDuration, crossfadeStyle, currentPlayer, currentPlayerNum, currentTime, duration, isTransitioning, nextPlayer, playerNum, setIsTransitioning, volume, } = args; const player = `player${playerNum}`; if (!isTransitioning) { if (currentTime > duration - crossfadeDuration) { nextPlayer.setVolume(0); nextPlayer.ref?.getInternalPlayer().play(); return setIsTransitioning(player); } return; } if (isTransitioning !== player && currentPlayerNum !== playerNum) { return; } const timeLeft = duration - currentTime; 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; isFlac: boolean; isTransitioning: boolean | string; nextPlayer: { ref: null | ReactPlayer; setVolume: (volume: number) => void; }; setIsTransitioning: Dispatch; }) { const { currentTime, duration, isFlac, isTransitioning, nextPlayer, setIsTransitioning } = args; if (!isTransitioning) { if (currentTime > duration - 2) { return setIsTransitioning(true); } return null; } const durationPadding = getDurationPadding(isFlac); if (currentTime + durationPadding >= duration) { return nextPlayer.ref ?.getInternalPlayer() ?.play() .catch(() => {}); } 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; } function getDurationPadding(isFlac: boolean) { switch (isFlac) { case false: return 0.116; case true: 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); }