201 lines
6.5 KiB
TypeScript
201 lines
6.5 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
|
|
function fmt(s: number): string {
|
|
if (!isFinite(s)) return "0:00";
|
|
const m = Math.floor(s / 60);
|
|
const sec = Math.floor(s % 60);
|
|
return `${m}:${sec.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
const IconPlay = () => (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
|
<polygon points="6,3 20,12 6,21" />
|
|
</svg>
|
|
);
|
|
|
|
const IconPause = () => (
|
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
|
<rect x="5" y="3" width="4" height="18" rx="1" />
|
|
<rect x="15" y="3" width="4" height="18" rx="1" />
|
|
</svg>
|
|
);
|
|
|
|
const IconVolume = ({ muted }: { muted: boolean }) => (
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
{muted
|
|
? <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z M19 12l2.5 2.5M21.5 12 19 14.5" />
|
|
: <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z" />}
|
|
</svg>
|
|
);
|
|
|
|
const IconFullscreen = () => (
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
|
</svg>
|
|
);
|
|
|
|
const HIDE_DELAY = 2500;
|
|
|
|
interface MediaPlayerProps {
|
|
src: string;
|
|
kind: "audio" | "video";
|
|
mime?: string;
|
|
}
|
|
|
|
export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
|
const mediaRef = useRef<HTMLMediaElement>(null);
|
|
const [playing, setPlaying] = useState(false);
|
|
const [current, setCurrent] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [dragging, setDragging] = useState(false);
|
|
const [volume, setVolume] = useState(1);
|
|
const [muted, setMuted] = useState(false);
|
|
const [controlsVisible, setControlsVisible] = useState(true);
|
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
const a = mediaRef.current!;
|
|
const onTime = () => { if (!dragging) setCurrent(a.currentTime); };
|
|
const onDuration = () => setDuration(a.duration);
|
|
const onEnded = () => setPlaying(false);
|
|
a.addEventListener("timeupdate", onTime);
|
|
a.addEventListener("durationchange", onDuration);
|
|
a.addEventListener("ended", onEnded);
|
|
return () => {
|
|
a.removeEventListener("timeupdate", onTime);
|
|
a.removeEventListener("durationchange", onDuration);
|
|
a.removeEventListener("ended", onEnded);
|
|
};
|
|
}, [dragging]);
|
|
|
|
// Show controls when paused; schedule hide when playing
|
|
useEffect(() => {
|
|
if (kind !== "video") return;
|
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
if (playing) {
|
|
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
|
} else {
|
|
setControlsVisible(true);
|
|
}
|
|
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
|
|
}, [playing, kind]);
|
|
|
|
const showControlsTemporarily = () => {
|
|
if (kind !== "video") return;
|
|
setControlsVisible(true);
|
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
if (playing) {
|
|
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
|
}
|
|
};
|
|
|
|
const toggle = () => {
|
|
const a = mediaRef.current!;
|
|
if (playing) { a.pause(); setPlaying(false); }
|
|
else { a.play(); setPlaying(true); }
|
|
};
|
|
|
|
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const v = Number(e.target.value);
|
|
setCurrent(v);
|
|
mediaRef.current!.currentTime = v;
|
|
};
|
|
|
|
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const v = Number(e.target.value);
|
|
setVolume(v);
|
|
mediaRef.current!.volume = v;
|
|
if (v > 0 && muted) { setMuted(false); mediaRef.current!.muted = false; }
|
|
};
|
|
|
|
const toggleMute = () => {
|
|
const next = !muted;
|
|
setMuted(next);
|
|
mediaRef.current!.muted = next;
|
|
};
|
|
|
|
const goFullscreen = () => {
|
|
(mediaRef.current as HTMLVideoElement).requestFullscreen?.();
|
|
};
|
|
|
|
const progress = duration > 0 ? current / duration : 0;
|
|
|
|
const controls = (
|
|
<>
|
|
<button type="button" className="audio-player-btn" onClick={toggle} aria-label={playing ? "Pause" : "Play"}>
|
|
{playing ? <IconPause /> : <IconPlay />}
|
|
</button>
|
|
|
|
<span className="audio-player-time">{fmt(current)}</span>
|
|
|
|
<div className="audio-player-track">
|
|
<div className="audio-player-fill" style={{ width: `${progress * 100}%` }} />
|
|
<input
|
|
type="range"
|
|
className="audio-player-range"
|
|
min={0} max={duration || 1} step={0.01} value={current}
|
|
onMouseDown={() => setDragging(true)}
|
|
onMouseUp={() => setDragging(false)}
|
|
onChange={seek}
|
|
aria-label="Seek"
|
|
/>
|
|
</div>
|
|
|
|
<span className="audio-player-time">{fmt(duration)}</span>
|
|
|
|
<div className="audio-player-volume">
|
|
<button type="button" className="audio-player-vol-btn" onClick={toggleMute} aria-label={muted ? "Unmute" : "Mute"}>
|
|
<IconVolume muted={muted} />
|
|
</button>
|
|
<div className="audio-player-track audio-player-track--volume">
|
|
<div className="audio-player-fill" style={{ width: `${(muted ? 0 : volume) * 100}%` }} />
|
|
<input
|
|
type="range"
|
|
className="audio-player-range"
|
|
min={0} max={1} step={0.01} value={muted ? 0 : volume}
|
|
onChange={changeVolume}
|
|
aria-label="Volume"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{kind === "video" && (
|
|
<button type="button" className="audio-player-vol-btn" onClick={goFullscreen} aria-label="Fullscreen">
|
|
<IconFullscreen />
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
if (kind === "video") {
|
|
return (
|
|
<div
|
|
className={`video-player${controlsVisible ? " video-player--controls-visible" : ""}`}
|
|
onMouseMove={showControlsTemporarily}
|
|
onMouseLeave={() => playing && setControlsVisible(false)}
|
|
>
|
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
|
<video
|
|
ref={mediaRef as React.RefObject<HTMLVideoElement>}
|
|
src={src}
|
|
preload="metadata"
|
|
className="video-player-video"
|
|
onClick={toggle}
|
|
>
|
|
{mime && <source src={src} type={mime} />}
|
|
</video>
|
|
<div className="video-player-controls audio-player">
|
|
{controls}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="audio-player">
|
|
<audio ref={mediaRef as React.RefObject<HTMLAudioElement>} src={src} preload="metadata" />
|
|
{controls}
|
|
</div>
|
|
);
|
|
}
|