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 { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { useSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url'; import { PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio'; import { useMpvSettings, usePlaybackSettings, usePlayerActions, usePlayerData, usePlayerMuted, usePlayerProperties, usePlayerVolume, } from '/@/renderer/store'; import { QueueSong } from '/@/shared/types/domain-types'; 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 playback = useMpvSettings(); const { webAudio } = useWebAudio(); const { crossfadeDuration, crossfadeStyle, speed, transitionType } = usePlayerProperties(); const isMuted = usePlayerMuted(); const volume = usePlayerVolume(); const { audioFadeOnStatusChange, preservePitch, transcode } = usePlaybackSettings(); const [localPlayerStatus, setLocalPlayerStatus] = useState(status); const [isTransitioning, setIsTransitioning] = useState(false); const fadeIntervalRef = useRef(null); const [player1Source, setPlayer1Source] = useState(null); const [player2Source, setPlayer2Source] = useState(null); // `react-player` may swap its underlying internal player when switching URLs // (e.g. file/http streams => HTMLMediaElement, YouTube => iframe player). A // MediaElementAudioSourceNode is permanently bound to a specific element, so we // must recreate the node when the element changes (or disconnect when it stops // being a media element). const player1InternalRef = useRef(null); const player2InternalRef = useRef(null); const player1SourceRef = useRef(null); const player2SourceRef = useRef(null); const player1ConnectInFlightRef = useRef>(null); const player2ConnectInFlightRef = useRef>(null); useEffect(() => { player1SourceRef.current = player1Source; }, [player1Source]); useEffect(() => { player2SourceRef.current = player2Source; }, [player2Source]); const fadeAndSetStatus = useCallback( async (startVolume: number, endVolume: number, duration: number, status: PlayerStatus) => { // Cancel any in-progress fade if (fadeIntervalRef.current) { clearInterval(fadeIntervalRef.current); fadeIntervalRef.current = null; } // Set initial volume immediately to ensure we start from the correct position // This is especially important when cancelling a previous fade playerRef.current?.setVolume(startVolume); const steps = duration / PLAY_PAUSE_FADE_INTERVAL; const volumeStep = (endVolume - startVolume) / steps; let currentStep = 0; const promise = new Promise((resolve) => { fadeIntervalRef.current = setInterval(() => { currentStep++; const newVolume = startVolume + volumeStep * currentStep; playerRef.current?.setVolume(newVolume); if (currentStep >= steps) { if (fadeIntervalRef.current) { clearInterval(fadeIntervalRef.current); fadeIntervalRef.current = null; } // Ensure final volume is exactly the target playerRef.current?.setVolume(endVolume); resolve(); } }, PLAY_PAUSE_FADE_INTERVAL); }); if (status === PlayerStatus.PAUSED) { await promise; setLocalPlayerStatus(status); } else if (status === PlayerStatus.PLAYING) { setLocalPlayerStatus(status); await promise; } }, [], ); 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()), hasNextSong: Boolean(player2), isTransitioning, nextPlayer: playerRef.current.player2(), playerNum: 1, setIsTransitioning, volume, }); break; case PlayerStyle.GAPLESS: gaplessHandler({ currentTime: e.playedSeconds, duration: getDuration(playerRef.current.player1()), isFlac: false, isTransitioning, nextPlayer: playerRef.current.player2(), setIsTransitioning, }); break; } }, [crossfadeDuration, crossfadeStyle, isTransitioning, num, player2, 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()), hasNextSong: Boolean(player1), isTransitioning, nextPlayer: playerRef.current.player1(), playerNum: 2, setIsTransitioning, volume, }); break; case PlayerStyle.GAPLESS: gaplessHandler({ currentTime: e.playedSeconds, duration: getDuration(playerRef.current.player2()), isFlac: false, isTransitioning, nextPlayer: playerRef.current.player1(), setIsTransitioning, }); break; } }, [crossfadeDuration, crossfadeStyle, isTransitioning, num, player1, transitionType, volume], ); const handleOnEndedPlayer1 = useCallback(() => { const promise = new Promise((resolve) => { mediaAutoNext(); resolve(true); }); promise.then(() => { playerRef.current?.player1()?.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()?.pause(); playerRef.current?.setVolume(volume); setIsTransitioning(false); }); }, [mediaAutoNext, volume]); const player = usePlayer(); usePlayerEvents( { onCurrentSongChange: () => { setIsTransitioning(false); }, onPlayerSeekToTimestamp: (properties) => { setIsTransitioning(false); 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()?.pause(); } else { playerRef.current?.player2()?.setVolume(volume); playerRef.current?.player1()?.setVolume(0); playerRef.current?.player1()?.pause(); } } if (num === 1) { playerRef.current?.player1()?.ref?.seekTo(timestamp); } else { playerRef.current?.player2()?.ref?.seekTo(timestamp); } }, onPlayerStatus: async (properties) => { setIsTransitioning(false); const status = properties.status; // Reset crossfade transition if paused during a crossfade transition if ( status === PlayerStatus.PAUSED && isTransitioning && transitionType === PlayerStyle.CROSSFADE ) { if (num === 1) { playerRef.current?.player1()?.setVolume(volume); playerRef.current?.player2()?.setVolume(0); playerRef.current?.player2()?.pause(); } else { playerRef.current?.player2()?.setVolume(volume); playerRef.current?.player1()?.setVolume(0); playerRef.current?.player1()?.pause(); } } if (audioFadeOnStatusChange) { 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); } } else { if (status === PlayerStatus.PAUSED) { playerRef.current?.setVolume(0); setLocalPlayerStatus(PlayerStatus.PAUSED); } else if (status === PlayerStatus.PLAYING) { playerRef.current?.setVolume(volume); setLocalPlayerStatus(PlayerStatus.PLAYING); } } }, onPlayerVolume: (properties) => { const volume = properties.volume; playerRef.current?.setVolume(volume); }, onQueueCleared: () => { player.mediaStop(); }, }, [volume, num, isTransitioning, transitionType, audioFadeOnStatusChange], ); // Cleanup fade interval on unmount useEffect(() => { return () => { if (fadeIntervalRef.current) { clearInterval(fadeIntervalRef.current); fadeIntervalRef.current = null; } }; }, []); useEffect(() => { if (localPlayerStatus !== PlayerStatus.PLAYING) { return; } const interval = setInterval(() => { const activePlayer = num === 1 ? playerRef.current?.player1() : playerRef.current?.player2(); if (!activePlayer) { return; } const currentTime = activePlayer.getCurrentTime(); if ( transitionType === PlayerStyle.CROSSFADE || transitionType === PlayerStyle.GAPLESS ) { setTimestamp(Number(currentTime.toFixed(0))); } }, 500); return () => clearInterval(interval); }, [localPlayerStatus, num, setTimestamp, transitionType]); const calculateReplayGain = useCallback( (song: QueueSong): number => { if (playback.replayGainMode === 'no') { return 1; } let gain: number | undefined; let peak: number | undefined; if (playback.replayGainMode === 'track') { gain = song.gain?.track ?? song.gain?.album; peak = song.peak?.track ?? song.peak?.album; } else { gain = song.gain?.album ?? song.gain?.track; peak = song.peak?.album ?? song.peak?.track; } if (gain === undefined) { gain = playback.replayGainFallbackDB; if (!gain) { return 1; } } if (peak === undefined) { peak = 1; } const preAmp = playback.replayGainPreampDB ?? 0; // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification§ion=19 // Normalized to max gain let expectedGain = 10 ** ((gain + preAmp) / 20); // Nothing in the system should allow this. But, in the case that preAmp is a // bad value (not a number, for example), a NaN gain will cause the entire system to panic if (isNaN(expectedGain)) { expectedGain = 1; } if (playback.replayGainClip) { return Math.min(expectedGain, 1 / peak); } return expectedGain; }, [ playback.replayGainClip, playback.replayGainFallbackDB, playback.replayGainMode, playback.replayGainPreampDB, ], ); useEffect(() => { if (!webAudio) return; if (player1 && player1Source && num === 1) { const newGain = calculateReplayGain(player1); // This error SHOULD never happen, as calculateReplayGain is expected to // always return a real value. However, to prevent app crash, check this just in case try { webAudio.gains[0].gain.setValueAtTime(Math.max(0, newGain), 0); } catch (error) { console.error('Error setting gain', error); } } }, [calculateReplayGain, num, player1, player1Source, volume, webAudio]); useEffect(() => { if (!webAudio) return; if (player2 && player2Source && num === 2) { const newGain = calculateReplayGain(player2); try { webAudio.gains[1].gain.setValueAtTime(Math.max(0, newGain), 0); } catch (error) { console.error('Error setting gain', error); } } }, [calculateReplayGain, num, player1, player2Source, player2, volume, webAudio]); const player1Url = useSongUrl(player1, num === 1, transcode); const player2Url = useSongUrl(player2, num === 2, transcode); const disconnectPlayerSource = useCallback( (playerNum: 1 | 2) => { const sourceRef = playerNum === 1 ? player1SourceRef : player2SourceRef; const setSource = playerNum === 1 ? setPlayer1Source : setPlayer2Source; const internalRef = playerNum === 1 ? player1InternalRef : player2InternalRef; if (sourceRef.current) { try { sourceRef.current.disconnect(); } catch { // ignore } } sourceRef.current = null; internalRef.current = null; setSource(null); }, [setPlayer1Source, setPlayer2Source], ); const connectPlayerToWebAudio = useCallback( async (playerNum: 1 | 2, player: ReactPlayer) => { if (!webAudio) return; const inFlightRef = playerNum === 1 ? player1ConnectInFlightRef : player2ConnectInFlightRef; if (inFlightRef.current) { await inFlightRef.current; return; } const internalRef = playerNum === 1 ? player1InternalRef : player2InternalRef; const sourceRef = playerNum === 1 ? player1SourceRef : player2SourceRef; const setSource = playerNum === 1 ? setPlayer1Source : setPlayer2Source; const gain = webAudio.gains[playerNum === 1 ? 0 : 1]; const task = (async () => { const internal = player.getInternalPlayer() as unknown; // YouTube (and some other sources) are not HTMLMediaElements, so WebAudio // can't attach; ensure we drop any stale node from a prior media element. if (!(internal instanceof HTMLMediaElement)) { disconnectPlayerSource(playerNum); return; } if (webAudio.context.state !== 'running') { try { await webAudio.context.resume(); } catch { // ignore resume failures; we'll try again on next ready } } // If the internal media element changed, we must recreate the source node. if (internalRef.current === internal && sourceRef.current) { return; } if (sourceRef.current) { try { sourceRef.current.disconnect(); } catch { // ignore } } internalRef.current = internal; try { const source = webAudio.context.createMediaElementSource(internal); source.connect(gain); sourceRef.current = source; setSource(source); } catch (error) { // Most commonly: trying to create another MediaElementSourceNode for the // same element, or attempting to attach a tainted/cross-origin element. console.error('Error connecting WebAudio source', { error, playerNum }); disconnectPlayerSource(playerNum); } })(); inFlightRef.current = task.finally(() => { inFlightRef.current = null; }); await inFlightRef.current; }, [disconnectPlayerSource, webAudio], ); const handlePlayer1Start = useCallback( async (player: ReactPlayer) => { await connectPlayerToWebAudio(1, player); }, [connectPlayerToWebAudio], ); const handlePlayer2Start = useCallback( async (player: ReactPlayer) => { await connectPlayerToWebAudio(2, player); }, [connectPlayerToWebAudio], ); return ( ); } function crossfadeHandler(args: { crossfadeDuration: number; crossfadeStyle: CrossfadeStyle; currentPlayer: { pause: () => void; ref: null | ReactPlayer; setVolume: (volume: number) => void; }; currentPlayerNum: number; currentTime: number; duration: number; hasNextSong: boolean; isTransitioning: boolean | string; nextPlayer: { pause: () => void; play: () => void; ref: null | ReactPlayer; setVolume: (volume: number) => void; }; playerNum: number; setIsTransitioning: Dispatch; volume: number; }) { const { crossfadeDuration, crossfadeStyle, currentPlayer, currentPlayerNum, currentTime, duration, hasNextSong, isTransitioning, nextPlayer, playerNum, setIsTransitioning, volume, } = args; const player = `player${playerNum}`; // If there is no next song to transition to, ensure we don't enter or stay in a transition if (!hasNextSong) { currentPlayer.setVolume(volume); nextPlayer.setVolume(0); nextPlayer.pause(); if (isTransitioning) { setIsTransitioning(false); } return; } if (!isTransitioning) { if (duration > 0 && currentTime > duration - crossfadeDuration) { nextPlayer.setVolume(0); nextPlayer.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: { play: () => void; 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) { nextPlayer.play(); return; } 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( player: | undefined | { getDuration: () => number; }, ) { return player?.getDuration?.() ?? 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); }