295 lines
8.1 KiB
TypeScript
295 lines
8.1 KiB
TypeScript
import {
|
|
CaretDownOutlined,
|
|
ControlOutlined,
|
|
MutedOutlined,
|
|
SoundOutlined,
|
|
} from '@ant-design/icons';
|
|
import { Button, InputNumber, Popover, Slider } from 'antd';
|
|
import cn from 'classnames';
|
|
import { observer } from 'mobx-react-lite';
|
|
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
|
|
import { PlayerContext } from 'App/components/Session/playerContext';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
function DropdownAudioPlayer({
|
|
audioEvents,
|
|
}: {
|
|
audioEvents: { payload: Record<any, any>; timestamp: number }[];
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { store } = useContext(PlayerContext);
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const [volume, setVolume] = useState(35);
|
|
const [delta, setDelta] = useState(0);
|
|
const [deltaInputValue, setDeltaInputValue] = useState(0);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
const lastPlayerTime = useRef(0);
|
|
const audioRefs = useRef<Record<string, HTMLAudioElement | null>>({});
|
|
const fileLengths = useRef<Record<string, number>>({});
|
|
const { time = 0, speed = 1, playing, sessionStart } = store?.get() ?? {};
|
|
|
|
const files = React.useMemo(
|
|
() =>
|
|
audioEvents.map((pa) => {
|
|
const data = pa.payload;
|
|
const nativeTs = data.timestamp;
|
|
const startTs = nativeTs
|
|
? nativeTs > sessionStart
|
|
? nativeTs - sessionStart
|
|
: nativeTs
|
|
: pa.timestamp - sessionStart;
|
|
return {
|
|
url: data.url,
|
|
timestamp: data.timestamp,
|
|
start: Math.max(0, startTs),
|
|
};
|
|
}),
|
|
[audioEvents.length, sessionStart],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
Object.entries(audioRefs.current).forEach(([url, audio]) => {
|
|
if (audio) {
|
|
audio.loop = false;
|
|
audio.addEventListener('loadedmetadata', () => {
|
|
fileLengths.current[url] = audio.duration;
|
|
});
|
|
}
|
|
});
|
|
}, [audioRefs.current]);
|
|
|
|
const toggleMute = () => {
|
|
Object.values(audioRefs.current).forEach((audio) => {
|
|
if (audio) {
|
|
audio.muted = !audio.muted;
|
|
}
|
|
});
|
|
setIsMuted(!isMuted);
|
|
if (!isMuted) {
|
|
onVolumeChange(0);
|
|
} else {
|
|
onVolumeChange(35);
|
|
}
|
|
};
|
|
|
|
const toggleVisible = () => {
|
|
setIsVisible(!isVisible);
|
|
};
|
|
|
|
const handleDelta = (value: any) => {
|
|
setDeltaInputValue(parseFloat(value));
|
|
};
|
|
|
|
const onSync = () => {
|
|
setDelta(deltaInputValue);
|
|
handleSeek(time + deltaInputValue * 1000);
|
|
};
|
|
|
|
const onCancel = () => {
|
|
setDeltaInputValue(0);
|
|
setIsVisible(false);
|
|
};
|
|
|
|
const onReset = () => {
|
|
setDelta(0);
|
|
setDeltaInputValue(0);
|
|
handleSeek(time);
|
|
};
|
|
|
|
const onVolumeChange = (value: number) => {
|
|
Object.values(audioRefs.current).forEach((audio) => {
|
|
if (audio) {
|
|
audio.volume = value / 100;
|
|
}
|
|
});
|
|
setVolume(value);
|
|
setIsMuted(value === 0);
|
|
};
|
|
|
|
const handleSeek = (timeMs: number) => {
|
|
Object.entries(audioRefs.current).forEach(([key, audio]) => {
|
|
if (audio) {
|
|
const file = files.find((f) => f.url === key);
|
|
if (file) {
|
|
const targetTime = (timeMs + delta * 1000 - file.start) / 1000;
|
|
const fileLength = fileLengths.current[key];
|
|
if (targetTime < 0 || (fileLength && targetTime > fileLength)) {
|
|
audio.pause();
|
|
audio.currentTime = 0;
|
|
} else {
|
|
audio.currentTime = targetTime;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const changePlaybackSpeed = (speed: number) => {
|
|
Object.values(audioRefs.current).forEach((audio) => {
|
|
if (audio) {
|
|
audio.playbackRate = speed;
|
|
}
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
const deltaMs = delta * 1000;
|
|
const deltaTime = Math.abs(lastPlayerTime.current - time - deltaMs);
|
|
if (deltaTime >= 250) {
|
|
handleSeek(time);
|
|
}
|
|
Object.entries(audioRefs.current).forEach(([url, audio]) => {
|
|
if (audio) {
|
|
const file = files.find((f) => f.url === url);
|
|
const fileLength = fileLengths.current[url];
|
|
if (file) {
|
|
if (fileLength && fileLength * 1000 + file.start < time) {
|
|
return;
|
|
}
|
|
if (time >= file.start) {
|
|
if (audio.paused && playing) {
|
|
audio.play();
|
|
}
|
|
} else {
|
|
audio.pause();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
lastPlayerTime.current = time + deltaMs;
|
|
}, [time, delta]);
|
|
|
|
useEffect(() => {
|
|
Object.values(audioRefs.current).forEach((audio) => {
|
|
if (audio) {
|
|
audio.muted = isMuted;
|
|
}
|
|
});
|
|
}, [isMuted]);
|
|
|
|
useEffect(() => {
|
|
changePlaybackSpeed(speed);
|
|
}, [speed]);
|
|
|
|
useEffect(() => {
|
|
Object.entries(audioRefs.current).forEach(([url, audio]) => {
|
|
if (audio) {
|
|
const file = files.find((f) => f.url === url);
|
|
const fileLength = fileLengths.current[url];
|
|
if (file) {
|
|
if (fileLength && fileLength * 1000 + file.start < time) {
|
|
audio.pause();
|
|
return;
|
|
}
|
|
if (playing && time >= file.start) {
|
|
audio.play();
|
|
} else {
|
|
audio.pause();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
setVolume(isMuted ? 0 : volume);
|
|
}, [playing]);
|
|
|
|
const buttonIcon =
|
|
'px-2 cursor-pointer border border-gray-light hover:border-main hover:text-main hover:z-10 h-fit';
|
|
return (
|
|
<div className="relative">
|
|
<div className="flex items-center" style={{ height: 24 }}>
|
|
<Popover
|
|
trigger="click"
|
|
content={
|
|
<div
|
|
className="flex flex-col gap-2 rounded"
|
|
style={{ height: 200 }}
|
|
>
|
|
<Slider vertical value={volume} onChange={onVolumeChange} />
|
|
<Button
|
|
className="flex items-center justify-center py-4 px-4"
|
|
onClick={toggleMute}
|
|
shape="circle"
|
|
>
|
|
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className={cn(buttonIcon, 'rounded-l')}>
|
|
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
|
|
</div>
|
|
</Popover>
|
|
<div
|
|
onClick={toggleVisible}
|
|
style={{ marginLeft: -1 }}
|
|
className={cn(buttonIcon, 'rounded-r')}
|
|
>
|
|
<CaretDownOutlined />
|
|
</div>
|
|
</div>
|
|
|
|
{isVisible ? (
|
|
<div
|
|
className="absolute left-1/2 top-0 border shadow border-gray-light rounded bg-white p-4 flex flex-col gap-4 mb-4"
|
|
style={{
|
|
width: 240,
|
|
transform: 'translate(-75%, -110%)',
|
|
zIndex: 101,
|
|
}}
|
|
>
|
|
<div className="font-semibold flex items-center gap-2">
|
|
<ControlOutlined />
|
|
<div>{t('Audio Track Synchronization')}</div>
|
|
</div>
|
|
<InputNumber
|
|
style={{ width: 180 }}
|
|
value={deltaInputValue}
|
|
size="small"
|
|
step="0.250"
|
|
name="audio delta"
|
|
formatter={(value) => `${value}s`}
|
|
parser={(value) => value?.replace('s', '') as unknown as number}
|
|
stringMode
|
|
onChange={handleDelta}
|
|
/>
|
|
<div className="w-full flex items-center gap-2">
|
|
<Button size="small" type="primary" onClick={onSync}>
|
|
{t('Sync')}
|
|
</Button>
|
|
<Button size="small" onClick={onCancel}>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
type="text"
|
|
className="ml-auto"
|
|
onClick={onReset}
|
|
>
|
|
{t('Reset')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div style={{ display: 'none' }}>
|
|
{files.map((file) => (
|
|
<audio
|
|
loop={false}
|
|
key={file.url}
|
|
ref={(el) => (audioRefs.current[file.url] = el)}
|
|
controls
|
|
muted={isMuted}
|
|
className="w-full"
|
|
style={{ height: 32 }}
|
|
>
|
|
<source src={file.url} type="audio/mpeg" />
|
|
{t('Your browser does not support the audio element.')}
|
|
</audio>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default observer(DropdownAudioPlayer);
|