feishin/src/renderer/components/audio-player/index.tsx

460 lines
18 KiB
TypeScript
Raw Normal View History

2024-09-01 08:26:30 -07:00
import {
useImperativeHandle,
forwardRef,
useRef,
useState,
useCallback,
useEffect,
useMemo,
} from 'react';
2022-12-19 15:59:14 -08:00
import isElectron from 'is-electron';
import type { ReactPlayerProps } from 'react-player';
import ReactPlayer from 'react-player/lazy';
2022-12-19 15:59:14 -08:00
import type { Song } from '/@/renderer/api/types';
import {
2023-07-01 19:10:05 -07:00
crossfadeHandler,
gaplessHandler,
2022-12-19 15:59:14 -08:00
} from '/@/renderer/components/audio-player/utils/list-handlers';
import { useSettingsStore, useSettingsStoreActions } from '/@/renderer/store/settings.store';
2022-12-19 15:59:14 -08:00
import type { CrossfadeStyle } from '/@/renderer/types';
import { PlaybackStyle, PlayerStatus } from '/@/renderer/types';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
2024-09-01 08:26:30 -07:00
import { getServerById, TranscodingConfig, usePlaybackSettings, useSpeed } from '/@/renderer/store';
import { toast } from '/@/renderer/components/toast';
2024-09-01 08:26:30 -07:00
import { api } from '/@/renderer/api';
2022-12-19 15:59:14 -08:00
interface AudioPlayerProps extends ReactPlayerProps {
2023-07-01 19:10:05 -07:00
crossfadeDuration: number;
crossfadeStyle: CrossfadeStyle;
currentPlayer: 1 | 2;
playbackStyle: PlaybackStyle;
2024-09-01 08:26:30 -07:00
player1?: Song;
player2?: Song;
2023-07-01 19:10:05 -07:00
status: PlayerStatus;
volume: number;
2022-12-19 15:59:14 -08:00
}
export type AudioPlayerProgress = {
2023-07-01 19:10:05 -07:00
loaded: number;
loadedSeconds: number;
played: number;
playedSeconds: number;
2022-12-19 15:59:14 -08:00
};
const getDuration = (ref: any) => {
2023-07-01 19:10:05 -07:00
return ref.current?.player?.player?.player?.duration;
2022-12-19 15:59:14 -08:00
};
2024-07-03 08:47:26 +00:00
// Credits: https://gist.github.com/novwhisky/8a1a0168b94f3b6abfaa?permalink_comment_id=1551393#gistcomment-1551393
// This is used so that the player will always have an <audio> element. This means that
// player1Source and player2Source are connected BEFORE the user presses play for
// the first time. This workaround is important for Safari, which seems to require the
// source to be connected PRIOR to resuming audio context
const EMPTY_SOURCE =
2024-07-03 08:47:26 +00:00
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';
2024-09-01 08:26:30 -07:00
const useSongUrl = (transcode: TranscodingConfig, current: boolean, song?: Song): string | null => {
const prior = useRef(['', '']);
return useMemo(() => {
if (song?.serverId) {
// If we are the current track, we do not want a transcoding
// reconfiguration to force a restart.
if (current && prior.current[0] === song.uniqueId) {
return prior.current[1];
}
if (!transcode.enabled) {
// transcoding disabled; save the result
prior.current = [song.uniqueId, song.streamUrl];
return song.streamUrl;
}
const result = api.controller.getTranscodingUrl({
apiClientProps: {
server: getServerById(song.serverId),
},
query: {
base: song.streamUrl,
...transcode,
},
})!;
// transcoding enabled; save the updated result
prior.current = [song.uniqueId, result];
return result;
}
// no track; clear result
prior.current = ['', ''];
return null;
}, [current, song?.uniqueId, song?.serverId, song?.streamUrl, transcode]);
};
2022-12-19 15:59:14 -08:00
export const AudioPlayer = forwardRef(
2023-07-01 19:10:05 -07:00
(
{
status,
playbackStyle,
crossfadeStyle,
crossfadeDuration,
currentPlayer,
autoNext,
player1,
player2,
muted,
volume,
}: AudioPlayerProps,
ref: any,
) => {
const player1Ref = useRef<ReactPlayer>(null);
const player2Ref = useRef<ReactPlayer>(null);
2023-07-01 19:10:05 -07:00
const [isTransitioning, setIsTransitioning] = useState(false);
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
const playback = useSettingsStore((state) => state.playback.mpvProperties);
const shouldUseWebAudio = useSettingsStore((state) => state.playback.webAudio);
const { resetSampleRate } = useSettingsStoreActions();
const playbackSpeed = useSpeed();
2024-09-01 08:26:30 -07:00
const { transcode } = usePlaybackSettings();
const stream1 = useSongUrl(transcode, currentPlayer === 1, player1);
const stream2 = useSongUrl(transcode, currentPlayer === 2, player2);
const { webAudio, setWebAudio } = useWebAudio();
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(
null,
);
const [player2Source, setPlayer2Source] = useState<MediaElementAudioSourceNode | null>(
null,
);
const calculateReplayGain = useCallback(
(song: Song): 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&section=19
// Normalized to max gain
const expectedGain = 10 ** ((gain + preAmp) / 20);
if (playback.replayGainClip) {
return Math.min(expectedGain, 1 / peak);
}
return expectedGain;
},
[
playback.replayGainClip,
playback.replayGainFallbackDB,
playback.replayGainMode,
playback.replayGainPreampDB,
],
);
useEffect(() => {
if (shouldUseWebAudio && 'AudioContext' in window) {
let context: AudioContext;
try {
context = new AudioContext({
latencyHint: 'playback',
sampleRate: playback.audioSampleRateHz || undefined,
});
} catch (error) {
// In practice, this should never be hit because the UI should validate
// the range. However, the actual supported range is not guaranteed
toast.error({ message: (error as Error).message });
context = new AudioContext({ latencyHint: 'playback' });
resetSampleRate();
}
const gain = context.createGain();
gain.connect(context.destination);
setWebAudio!({ context, gain });
return () => {
return context.close();
};
}
return () => {};
// Intentionally ignore the sample rate dependency, as it makes things really messy
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
2022-12-19 15:59:14 -08:00
2023-07-01 19:10:05 -07:00
useImperativeHandle(ref, () => ({
get player1() {
return player1Ref?.current;
},
get player2() {
return player2Ref?.current;
},
}));
2022-12-19 15:59:14 -08:00
2023-07-01 19:10:05 -07:00
const handleOnEnded = () => {
autoNext();
setIsTransitioning(false);
};
2022-12-19 15:59:14 -08:00
2023-07-01 19:10:05 -07:00
useEffect(() => {
if (status === PlayerStatus.PLAYING) {
if (currentPlayer === 1) {
// calling play() is not necessarily a safe option (https://developer.chrome.com/blog/play-request-was-interrupted)
// In practice, this failure is only likely to happen when using the 0-second wav:
// play() + play() in rapid succession will cause problems as the frist one ends the track.
player1Ref.current
?.getInternalPlayer()
?.play()
.catch(() => {});
2023-07-01 19:10:05 -07:00
} else {
player2Ref.current
?.getInternalPlayer()
?.play()
.catch(() => {});
2023-07-01 19:10:05 -07:00
}
} else {
player1Ref.current?.getInternalPlayer()?.pause();
player2Ref.current?.getInternalPlayer()?.pause();
}
}, [currentPlayer, status]);
2022-12-19 15:59:14 -08:00
2023-07-01 19:10:05 -07:00
const handleCrossfade1 = useCallback(
(e: AudioPlayerProgress) => {
return crossfadeHandler({
currentPlayer,
currentPlayerRef: player1Ref,
currentTime: e.playedSeconds,
duration: getDuration(player1Ref),
fadeDuration: crossfadeDuration,
fadeType: crossfadeStyle,
isTransitioning,
nextPlayerRef: player2Ref,
player: 1,
setIsTransitioning,
volume,
});
},
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
);
2022-12-19 15:59:14 -08:00
2023-07-01 19:10:05 -07:00
const handleCrossfade2 = useCallback(
(e: AudioPlayerProgress) => {
return crossfadeHandler({
currentPlayer,
currentPlayerRef: player2Ref,
currentTime: e.playedSeconds,
duration: getDuration(player2Ref),
fadeDuration: crossfadeDuration,
fadeType: crossfadeStyle,
isTransitioning,
nextPlayerRef: player1Ref,
player: 2,
setIsTransitioning,
volume,
});
},
[crossfadeDuration, crossfadeStyle, currentPlayer, isTransitioning, volume],
);
2022-12-19 15:59:14 -08:00
2023-07-01 19:10:05 -07:00
const handleGapless1 = useCallback(
(e: AudioPlayerProgress) => {
return gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(player1Ref),
isFlac: player1?.container === 'flac',
isTransitioning,
nextPlayerRef: player2Ref,
setIsTransitioning,
});
},
[isTransitioning, player1?.container],
);
2022-12-19 15:59:14 -08:00
2023-07-01 19:10:05 -07:00
const handleGapless2 = useCallback(
(e: AudioPlayerProgress) => {
return gaplessHandler({
currentTime: e.playedSeconds,
duration: getDuration(player2Ref),
isFlac: player2?.container === 'flac',
isTransitioning,
nextPlayerRef: player1Ref,
setIsTransitioning,
});
},
[isTransitioning, player2?.container],
);
2022-12-19 15:59:14 -08:00
2023-07-01 19:10:05 -07:00
useEffect(() => {
// Not standard, just used in chromium-based browsers. See
// https://developer.chrome.com/blog/audiocontext-setsinkid/.
// If the isElectron() check is every removed, fix this.
if (isElectron() && webAudio && 'setSinkId' in webAudio.context && audioDeviceId) {
const setSink = async () => {
try {
if (audioDeviceId !== 'default') {
// @ts-ignore
await webAudio.context.setSinkId(audioDeviceId);
} else {
// @ts-ignore
await webAudio.context.setSinkId('');
}
} catch (error) {
toast.error({ message: `Error setting sink: ${(error as Error).message}` });
}
};
setSink();
2023-07-01 19:10:05 -07:00
}
}, [audioDeviceId, webAudio]);
2022-12-19 15:59:14 -08:00
useEffect(() => {
if (!webAudio) return;
const sources = [player1Source ? player1 : null, player2Source ? player2 : null];
const current = sources[currentPlayer - 1];
// Set the current replaygain
if (current) {
const newVolume = calculateReplayGain(current) * volume;
webAudio.gain.gain.setValueAtTime(Math.max(0, newVolume), 0);
}
// Set the next track replaygain right before the end of this track
// Attempt to prevent pop-in for web audio.
const next = sources[3 - currentPlayer];
if (next && current) {
const newVolume = calculateReplayGain(next) * volume;
webAudio.gain.gain.setValueAtTime(
Math.max(0, newVolume),
Math.max(0, (current.duration - 1) / 1000),
);
}
}, [
calculateReplayGain,
currentPlayer,
player1,
player1Source,
player2,
player2Source,
volume,
webAudio,
]);
const handlePlayer1Start = useCallback(
async (player: ReactPlayer) => {
if (!webAudio) return;
if (player1Source) {
// This should fire once, only if the source is real (meaning we
// saw the dummy source) and the context is not ready
if (webAudio.context.state !== 'running') {
await webAudio.context.resume();
}
return;
}
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
if (internal) {
const { context, gain } = webAudio;
const source = context.createMediaElementSource(internal);
source.connect(gain);
setPlayer1Source(source);
}
},
[player1Source, webAudio],
);
const handlePlayer2Start = useCallback(
async (player: ReactPlayer) => {
if (!webAudio) return;
if (player2Source) {
if (webAudio.context.state !== 'running') {
await webAudio.context.resume();
}
return;
}
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
if (internal) {
const { context, gain } = webAudio;
const source = context.createMediaElementSource(internal);
source.connect(gain);
setPlayer2Source(source);
}
},
[player2Source, webAudio],
);
// Bugfix for Safari: rather than use the `<audio>` volume (which doesn't work),
// use the GainNode to scale the volume. In this case, for compatibility with
// other browsers, set the `<audio>` volume to 1
2023-07-01 19:10:05 -07:00
return (
<>
<ReactPlayer
ref={player1Ref}
config={{
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
}}
2023-07-01 19:10:05 -07:00
height={0}
muted={muted}
playbackRate={playbackSpeed}
2023-07-01 19:10:05 -07:00
playing={currentPlayer === 1 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
2024-09-01 08:26:30 -07:00
url={stream1 || EMPTY_SOURCE}
volume={webAudio ? 1 : volume}
2023-07-01 19:10:05 -07:00
width={0}
// If there is no stream url, we do not need to handle when the audio finishes
2024-09-01 08:26:30 -07:00
onEnded={stream1 ? handleOnEnded : undefined}
2023-07-01 19:10:05 -07:00
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless1 : handleCrossfade1
}
onReady={handlePlayer1Start}
2023-07-01 19:10:05 -07:00
/>
<ReactPlayer
ref={player2Ref}
config={{
file: { attributes: { crossOrigin: 'anonymous' }, forceAudio: true },
}}
2023-07-01 19:10:05 -07:00
height={0}
muted={muted}
playbackRate={playbackSpeed}
2023-07-01 19:10:05 -07:00
playing={currentPlayer === 2 && status === PlayerStatus.PLAYING}
progressInterval={isTransitioning ? 10 : 250}
2024-09-01 08:26:30 -07:00
url={stream2 || EMPTY_SOURCE}
volume={webAudio ? 1 : volume}
2023-07-01 19:10:05 -07:00
width={0}
2024-09-01 08:26:30 -07:00
onEnded={stream2 ? handleOnEnded : undefined}
2023-07-01 19:10:05 -07:00
onProgress={
playbackStyle === PlaybackStyle.GAPLESS ? handleGapless2 : handleCrossfade2
}
onReady={handlePlayer2Start}
2023-07-01 19:10:05 -07:00
/>
</>
);
},
2022-12-19 15:59:14 -08:00
);