add waveform playerbar slider

This commit is contained in:
jeffvli
2025-11-18 02:41:17 -08:00
parent 142a6d6512
commit 7b9007c699
13 changed files with 1162 additions and 56 deletions
@@ -0,0 +1,272 @@
import type { RefObject } from 'react';
import type WaveSurfer from 'wavesurfer.js';
import { useWavesurfer } from '@wavesurfer/react';
import { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types';
import { convertToLogVolume } from '/@/renderer/features/player/audio-player/utils/player-utils';
import { PlayerStatus } from '/@/shared/types/types';
export interface WaveSurferPlayerEngineHandle extends AudioPlayer {
player1(): {
ref: null | WaveSurfer;
setVolume: (volume: number) => void;
};
player2(): {
ref: null | WaveSurfer;
setVolume: (volume: number) => void;
};
}
interface WaveSurferPlayerEngineProps {
isMuted: boolean;
isTransitioning: boolean;
onEndedPlayer1: () => void;
onEndedPlayer2: () => void;
onProgressPlayer1: (e: PlayerOnProgressProps) => void;
onProgressPlayer2: (e: PlayerOnProgressProps) => void;
playerNum: number;
playerRef: RefObject<null | WaveSurferPlayerEngineHandle>;
playerStatus: PlayerStatus;
speed?: number;
src1: string | undefined;
src2: string | undefined;
volume: number;
}
// 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 =
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV';
export const WaveSurferPlayerEngine = (props: WaveSurferPlayerEngineProps) => {
const {
isMuted,
isTransitioning,
onEndedPlayer1,
onEndedPlayer2,
onProgressPlayer1,
onProgressPlayer2,
playerNum,
playerRef,
playerStatus,
speed,
src1,
src2,
volume,
} = props;
const container1Ref = useRef<HTMLDivElement>(null);
const container2Ref = useRef<HTMLDivElement>(null);
const [internalVolume1, setInternalVolume1] = useState(volume / 100 || 0);
const [internalVolume2, setInternalVolume2] = useState(volume / 100 || 0);
const { wavesurfer: wavesurfer1 } = useWavesurfer({
barWidth: 0,
container: container1Ref,
cursorColor: 'transparent',
height: 0,
interact: false,
normalize: false,
progressColor: 'transparent',
url: src1 || EMPTY_SOURCE,
waveColor: 'transparent',
});
const { wavesurfer: wavesurfer2 } = useWavesurfer({
barWidth: 0,
container: container2Ref,
cursorColor: 'transparent',
height: 0,
interact: false,
normalize: false,
progressColor: 'transparent',
url: src2 || EMPTY_SOURCE,
waveColor: 'transparent',
});
// Handle volume changes
useEffect(() => {
if (wavesurfer1) {
const logVolume1 = convertToLogVolume(internalVolume1);
wavesurfer1.setVolume(isMuted ? 0 : logVolume1);
}
}, [wavesurfer1, internalVolume1, isMuted]);
useEffect(() => {
if (wavesurfer2) {
const logVolume2 = convertToLogVolume(internalVolume2);
wavesurfer2.setVolume(isMuted ? 0 : logVolume2);
}
}, [wavesurfer2, internalVolume2, isMuted]);
// Handle playback rate (speed)
useEffect(() => {
if (wavesurfer1 && speed) {
wavesurfer1.setPlaybackRate(speed);
}
}, [wavesurfer1, speed]);
useEffect(() => {
if (wavesurfer2 && speed) {
wavesurfer2.setPlaybackRate(speed);
}
}, [wavesurfer2, speed]);
// Handle play/pause based on playerNum and status
useEffect(() => {
if (!wavesurfer1 || !wavesurfer2) return;
if (playerNum === 1 && playerStatus === PlayerStatus.PLAYING) {
wavesurfer1.play();
} else {
wavesurfer1.pause();
}
if (playerNum === 2 && playerStatus === PlayerStatus.PLAYING) {
wavesurfer2.play();
} else {
wavesurfer2.pause();
}
}, [wavesurfer1, wavesurfer2, playerNum, playerStatus]);
// Handle progress updates for player1
useEffect(() => {
if (!wavesurfer1 || !src1) return;
const updateProgress = () => {
const currentTime = wavesurfer1.getCurrentTime();
const duration = wavesurfer1.getDuration();
if (duration > 0) {
onProgressPlayer1({
played: currentTime / duration,
playedSeconds: currentTime,
});
}
};
const interval = setInterval(updateProgress, isTransitioning ? 10 : 250);
return () => clearInterval(interval);
}, [wavesurfer1, src1, isTransitioning, onProgressPlayer1]);
// Handle progress updates for player2
useEffect(() => {
if (!wavesurfer2 || !src2) return;
const updateProgress = () => {
const currentTime = wavesurfer2.getCurrentTime();
const duration = wavesurfer2.getDuration();
if (duration > 0) {
onProgressPlayer2({
played: currentTime / duration,
playedSeconds: currentTime,
});
}
};
const interval = setInterval(updateProgress, isTransitioning ? 10 : 250);
return () => clearInterval(interval);
}, [wavesurfer2, src2, isTransitioning, onProgressPlayer2]);
// Handle ended events
useEffect(() => {
if (!wavesurfer1 || !src1) return;
const handleEnded = () => {
onEndedPlayer1();
};
wavesurfer1.on('finish', handleEnded);
return () => {
wavesurfer1.un('finish', handleEnded);
};
}, [wavesurfer1, src1, onEndedPlayer1]);
useEffect(() => {
if (!wavesurfer2 || !src2) return;
const handleEnded = () => {
onEndedPlayer2();
};
wavesurfer2.on('finish', handleEnded);
return () => {
wavesurfer2.un('finish', handleEnded);
};
}, [wavesurfer2, src2, onEndedPlayer2]);
useImperativeHandle<WaveSurferPlayerEngineHandle, WaveSurferPlayerEngineHandle>(
playerRef,
() => ({
decreaseVolume(by: number) {
setInternalVolume1(Math.max(0, internalVolume1 - by / 100));
setInternalVolume2(Math.max(0, internalVolume2 - by / 100));
},
increaseVolume(by: number) {
setInternalVolume1(Math.min(1, internalVolume1 + by / 100));
setInternalVolume2(Math.min(1, internalVolume2 + by / 100));
},
pause() {
wavesurfer1?.pause();
wavesurfer2?.pause();
},
play() {
if (playerNum === 1) {
wavesurfer1?.play();
} else {
wavesurfer2?.play();
}
},
player1() {
return {
ref: wavesurfer1 || null,
setVolume: (volume: number) => setInternalVolume1(volume / 100 || 0),
};
},
player2() {
return {
ref: wavesurfer2 || null,
setVolume: (volume: number) => setInternalVolume2(volume / 100 || 0),
};
},
seekTo(seekTo: number) {
if (playerNum === 1) {
wavesurfer1?.seekTo(seekTo);
} else {
wavesurfer2?.seekTo(seekTo);
}
},
setVolume(volume: number) {
setInternalVolume1(volume / 100 || 0);
setInternalVolume2(volume / 100 || 0);
},
setVolume1(volume: number) {
setInternalVolume1(volume / 100 || 0);
},
setVolume2(volume: number) {
setInternalVolume2(volume / 100 || 0);
},
}),
[wavesurfer1, wavesurfer2, playerNum, internalVolume1, internalVolume2],
);
return (
<div id="wavesurfer-player-engine" style={{ display: 'none' }}>
{Boolean(src1) && <div id="wavesurfer-player-1" ref={container1Ref} />}
{Boolean(src2) && <div id="wavesurfer-player-2" ref={container2Ref} />}
</div>
);
};
WaveSurferPlayerEngine.displayName = 'WaveSurferPlayerEngine';
@@ -0,0 +1,349 @@
import type { Dispatch } from 'react';
import type WaveSurfer from 'wavesurfer.js';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
WaveSurferPlayerEngine,
WaveSurferPlayerEngineHandle,
} from '/@/renderer/features/player/audio-player/engine/wavesurfer-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 { PlayerStatus, PlayerStyle } from '/@/shared/types/types';
const PLAY_PAUSE_FADE_DURATION = 300;
const PLAY_PAUSE_FADE_INTERVAL = 10;
export function WaveSurferPlayer() {
const playerRef = useRef<null | WaveSurferPlayerEngineHandle>(null);
const { num, player1, player2, status } = usePlayerData();
const { mediaAutoNext, setTimestamp } = usePlayerActions();
const { crossfadeDuration, 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,
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, isTransitioning, num, transitionType, volume],
);
const onProgressPlayer2 = useCallback(
(e: PlayerOnProgressProps) => {
if (!playerRef.current?.player2()) {
return;
}
switch (transitionType) {
case PlayerStyle.CROSSFADE:
crossfadeHandler({
crossfadeDuration: crossfadeDuration,
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, isTransitioning, num, transitionType, volume],
);
const handleOnEndedPlayer1 = useCallback(() => {
const promise = new Promise((resolve) => {
mediaAutoNext();
resolve(true);
});
promise.then(() => {
playerRef.current?.player1()?.ref?.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?.pause();
playerRef.current?.setVolume(volume);
setIsTransitioning(false);
});
}, [mediaAutoNext, volume]);
usePlayerEvents(
{
onPlayerSeekToTimestamp: (properties) => {
const timestamp = properties.timestamp;
const activePlayer =
num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();
const wavesurfer = activePlayer?.ref;
if (wavesurfer) {
const duration = wavesurfer.getDuration();
if (duration > 0) {
// Convert timestamp to ratio (0-1) for wavesurfer
const ratio = timestamp / duration;
wavesurfer.seekTo(ratio);
}
}
},
onPlayerStatus: async (properties) => {
const status = properties.status;
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],
);
useEffect(() => {
if (localPlayerStatus !== PlayerStatus.PLAYING) {
return;
}
const interval = setInterval(() => {
const activePlayer =
num === 1 ? playerRef.current?.player1() : playerRef.current?.player2();
const wavesurfer = activePlayer?.ref;
if (!wavesurfer) {
return;
}
const currentTime = wavesurfer.getCurrentTime();
if (
transitionType === PlayerStyle.CROSSFADE ||
transitionType === PlayerStyle.GAPLESS
) {
setTimestamp(Number(currentTime.toFixed(0)));
}
}, 500);
return () => clearInterval(interval);
}, [localPlayerStatus, num, setTimestamp, transitionType]);
useMainPlayerListener();
return (
<WaveSurferPlayerEngine
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;
currentPlayer: {
ref: null | WaveSurfer;
setVolume: (volume: number) => void;
};
currentPlayerNum: number;
currentTime: number;
duration: number;
isTransitioning: boolean | string;
nextPlayer: {
ref: null | WaveSurfer;
setVolume: (volume: number) => void;
};
playerNum: number;
setIsTransitioning: Dispatch<boolean | string>;
volume: number;
}) {
const {
crossfadeDuration,
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?.play();
return setIsTransitioning(player);
}
return;
}
if (isTransitioning !== player && currentPlayerNum !== playerNum) {
return;
}
const timeLeft = duration - currentTime;
// Calculate the volume levels based on time remaining
const currentPlayerVolume = (timeLeft / crossfadeDuration) * volume;
const nextPlayerVolume = ((crossfadeDuration - timeLeft) / crossfadeDuration) * volume;
// Set volumes for both players
currentPlayer.setVolume(currentPlayerVolume);
nextPlayer.setVolume(nextPlayerVolume);
}
function gaplessHandler(args: {
currentTime: number;
duration: number;
isFlac: boolean;
isTransitioning: boolean | string;
nextPlayer: {
ref: null | WaveSurfer;
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?.play().catch(() => {});
}
return null;
}
function getDuration(ref: null | undefined | WaveSurfer) {
return ref?.getDuration() || 0;
}
function getDurationPadding(isFlac: boolean) {
switch (isFlac) {
case false:
return 0.116;
case true:
return 0.065;
}
}