Files
feishin/src/renderer/features/player/audio-player/web-player.tsx
T

479 lines
16 KiB
TypeScript

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 | WebPlayerEngineHandle>(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<PlayerStatus>(status);
const [isTransitioning, setIsTransitioning] = useState<boolean | string>(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 (
<WebPlayerEngine
isMuted={isMuted}
isTransitioning={Boolean(isTransitioning)}
onEndedPlayer1={handleOnEndedPlayer1}
onEndedPlayer2={handleOnEndedPlayer2}
onProgressPlayer1={onProgressPlayer1}
onProgressPlayer2={onProgressPlayer2}
playerNum={num}
playerRef={playerRef}
playerStatus={localPlayerStatus}
speed={speed}
src1={player1?.streamUrl}
src2={player2?.streamUrl}
volume={volume}
/>
);
}
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<boolean | string>;
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<boolean | string>;
}) {
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);
}