mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
768 lines
26 KiB
TypeScript
768 lines
26 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 { 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 | WebPlayerEngineHandle>(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<PlayerStatus>(status);
|
|
const [isTransitioning, setIsTransitioning] = useState<boolean | string>(false);
|
|
const fadeIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(null);
|
|
const [player2Source, setPlayer2Source] = useState<MediaElementAudioSourceNode | null>(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<HTMLMediaElement | null>(null);
|
|
const player2InternalRef = useRef<HTMLMediaElement | null>(null);
|
|
const player1SourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
|
const player2SourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
|
const player1ConnectInFlightRef = useRef<null | Promise<void>>(null);
|
|
const player2ConnectInFlightRef = useRef<null | Promise<void>>(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<void>((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 (
|
|
<WebPlayerEngine
|
|
isMuted={isMuted}
|
|
isTransitioning={Boolean(isTransitioning)}
|
|
onEndedPlayer1={handleOnEndedPlayer1}
|
|
onEndedPlayer2={handleOnEndedPlayer2}
|
|
onProgressPlayer1={onProgressPlayer1}
|
|
onProgressPlayer2={onProgressPlayer2}
|
|
onStartedPlayer1={handlePlayer1Start}
|
|
onStartedPlayer2={handlePlayer2Start}
|
|
playerNum={num}
|
|
playerRef={playerRef}
|
|
playerStatus={localPlayerStatus}
|
|
preservesPitch={preservePitch}
|
|
speed={speed}
|
|
src1={player1Url}
|
|
src2={player2Url}
|
|
volume={volume}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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<boolean | string>;
|
|
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<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) {
|
|
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);
|
|
}
|