You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
206 lines
7.3 KiB
206 lines
7.3 KiB
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import "./index.scss";
|
|
|
|
function formatAudioTime(seconds: number): string {
|
|
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
|
|
const total = Math.floor(safe);
|
|
const mins = Math.floor(total / 60);
|
|
const secs = total % 60;
|
|
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
|
}
|
|
|
|
export type AssetAudioPlayerHandle = {
|
|
togglePlay: () => void;
|
|
};
|
|
|
|
export interface AssetAudioPlayerProps {
|
|
src: string;
|
|
resetKey?: string | number;
|
|
autoPlay?: boolean;
|
|
autoAdvanceOnEnded?: boolean;
|
|
className?: string;
|
|
onPlayingChange?: (playing: boolean) => void;
|
|
canGoPrevious?: boolean;
|
|
canGoNext?: boolean;
|
|
onPrevious?: () => void;
|
|
onNext?: () => void;
|
|
}
|
|
|
|
export const AssetAudioPlayer = forwardRef<AssetAudioPlayerHandle, AssetAudioPlayerProps>(function AssetAudioPlayer(
|
|
{ src, resetKey, autoPlay = false, autoAdvanceOnEnded = false, className, onPlayingChange, canGoPrevious = false, canGoNext = false, onPrevious, onNext },
|
|
forwardedRef,
|
|
) {
|
|
const { t } = useTranslation();
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
const progressTrackRef = useRef<HTMLDivElement>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
const voiceSrc = String(src ?? "").trim();
|
|
|
|
useEffect(() => {
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
const audio = audioRef.current;
|
|
if (audio) {
|
|
audio.pause();
|
|
audio.currentTime = 0;
|
|
}
|
|
}, [voiceSrc, resetKey]);
|
|
|
|
useEffect(() => {
|
|
if (!autoPlay || !voiceSrc) return;
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
void audio.play().catch(() => { });
|
|
}, [autoPlay, voiceSrc, resetKey]);
|
|
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio || !voiceSrc) return;
|
|
|
|
const syncDuration = () => {
|
|
setDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
|
|
};
|
|
const onTimeUpdate = () => setCurrentTime(audio.currentTime);
|
|
const onEnded = () => {
|
|
setIsPlaying(false);
|
|
onPlayingChange?.(false);
|
|
if (autoAdvanceOnEnded && canGoNext && onNext) {
|
|
onNext();
|
|
}
|
|
};
|
|
const onPlay = () => {
|
|
setIsPlaying(true);
|
|
onPlayingChange?.(true);
|
|
};
|
|
const onPause = () => {
|
|
setIsPlaying(false);
|
|
onPlayingChange?.(false);
|
|
};
|
|
|
|
audio.addEventListener("timeupdate", onTimeUpdate);
|
|
audio.addEventListener("loadedmetadata", syncDuration);
|
|
audio.addEventListener("durationchange", syncDuration);
|
|
audio.addEventListener("ended", onEnded);
|
|
audio.addEventListener("play", onPlay);
|
|
audio.addEventListener("pause", onPause);
|
|
|
|
return () => {
|
|
audio.removeEventListener("timeupdate", onTimeUpdate);
|
|
audio.removeEventListener("loadedmetadata", syncDuration);
|
|
audio.removeEventListener("durationchange", syncDuration);
|
|
audio.removeEventListener("ended", onEnded);
|
|
audio.removeEventListener("play", onPlay);
|
|
audio.removeEventListener("pause", onPause);
|
|
audio.pause();
|
|
};
|
|
}, [autoAdvanceOnEnded, canGoNext, onNext, onPlayingChange, voiceSrc]);
|
|
|
|
const togglePlay = useCallback(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
if (audio.paused) {
|
|
void audio.play().catch(() => { });
|
|
return;
|
|
}
|
|
audio.pause();
|
|
}, []);
|
|
|
|
useImperativeHandle(forwardedRef, () => ({ togglePlay }), [togglePlay]);
|
|
|
|
const seekByClientX = useCallback((clientX: number) => {
|
|
const audio = audioRef.current;
|
|
const track = progressTrackRef.current;
|
|
if (!audio || !track || !Number.isFinite(audio.duration) || audio.duration <= 0) return;
|
|
const rect = track.getBoundingClientRect();
|
|
if (rect.width <= 0) return;
|
|
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
|
audio.currentTime = ratio * audio.duration;
|
|
}, []);
|
|
|
|
const seekWithinTrack = useCallback((delta: number) => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
const max = Number.isFinite(audio.duration) ? audio.duration : 0;
|
|
const next = audio.currentTime + delta;
|
|
audio.currentTime = max > 0 ? Math.min(Math.max(0, next), max) : Math.max(0, next);
|
|
}, []);
|
|
|
|
const handlePreviousTrack = useCallback(() => {
|
|
if (!canGoPrevious || !onPrevious) return;
|
|
onPrevious();
|
|
}, [canGoPrevious, onPrevious]);
|
|
|
|
const handleNextTrack = useCallback(() => {
|
|
if (!canGoNext || !onNext) return;
|
|
onNext();
|
|
}, [canGoNext, onNext]);
|
|
|
|
if (!voiceSrc) return null;
|
|
|
|
const progressPercent = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
|
|
|
return (
|
|
<div className={classNames("assetDetailsAudioPlayer", className)}>
|
|
<audio ref={audioRef} className="assetDetailsAudioPlayer__engine" src={voiceSrc} preload="metadata" />
|
|
<div className="assetDetailsAudioPlayer__controls">
|
|
<button
|
|
type="button"
|
|
className="assetDetailsAudioPlayer__skipBtn assetDetailsAudioPlayer__skipBtn_rewind"
|
|
onClick={handlePreviousTrack}
|
|
disabled={!canGoPrevious || !onPrevious}
|
|
aria-label={t("pages.assetDetails.audioPreviousTrack")}
|
|
>
|
|
<img src={new URL("/src/assets/images/ic_audio_forward.png", import.meta.url).href} alt="" />
|
|
</button>
|
|
<button type="button" className="assetDetailsAudioPlayer__playBtn" onClick={togglePlay} aria-label={isPlaying ? t("pages.assetDetails.audioPause") : t("pages.assetDetails.audioPlay")}>
|
|
{isPlaying ? <img src={new URL("/src/assets/images/ic_pause_gray.png", import.meta.url).href} alt="" /> : <img src={new URL("/src/assets/images/ic_play_gray.png", import.meta.url).href} alt="" />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="assetDetailsAudioPlayer__skipBtn assetDetailsAudioPlayer__skipBtn_forward"
|
|
onClick={handleNextTrack}
|
|
disabled={!canGoNext || !onNext}
|
|
aria-label={t("pages.assetDetails.audioNextTrack")}
|
|
>
|
|
<img src={new URL("/src/assets/images/ic_audio_forward.png", import.meta.url).href} alt="" />
|
|
</button>
|
|
</div>
|
|
<div className="assetDetailsAudioPlayer__progressRow">
|
|
<span className="assetDetailsAudioPlayer__time" aria-hidden>
|
|
{formatAudioTime(currentTime)}
|
|
</span>
|
|
<div
|
|
ref={progressTrackRef}
|
|
className="assetDetailsAudioPlayer__track"
|
|
role="slider"
|
|
aria-label={t("pages.assetDetails.audioProgress")}
|
|
aria-valuemin={0}
|
|
aria-valuemax={Math.floor(duration)}
|
|
aria-valuenow={Math.floor(currentTime)}
|
|
tabIndex={0}
|
|
onClick={(event) => seekByClientX(event.clientX)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "ArrowLeft") {
|
|
event.preventDefault();
|
|
seekWithinTrack(-5);
|
|
}
|
|
if (event.key === "ArrowRight") {
|
|
event.preventDefault();
|
|
seekWithinTrack(5);
|
|
}
|
|
}}
|
|
>
|
|
<div className="assetDetailsAudioPlayer__trackFill" style={{ width: `${progressPercent}%` }} />
|
|
</div>
|
|
<span className="assetDetailsAudioPlayer__time" aria-hidden>
|
|
{formatAudioTime(duration)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|