218 lines
7.5 KiB
TypeScript
218 lines
7.5 KiB
TypeScript
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
|
import Hls from "hls.js";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { FormattedMessage } from "react-intl";
|
|
import { StreamState } from "..";
|
|
import { Icon } from "./icon";
|
|
import { ProgressBar } from "./progress-bar";
|
|
import { Menu, MenuItem } from "@szhsin/react-menu";
|
|
|
|
export enum VideoStatus {
|
|
Online = "online",
|
|
Offline = "offline",
|
|
}
|
|
|
|
export interface VideoPlayerProps {
|
|
stream?: string;
|
|
status?: string;
|
|
poster?: string;
|
|
}
|
|
|
|
export default function LiveVideoPlayer(props: VideoPlayerProps) {
|
|
const video = useRef<HTMLVideoElement>(null);
|
|
const hlsObj = useRef<Hls>(null);
|
|
const streamCached = useMemo(() => props.stream, [props.stream]);
|
|
const [status, setStatus] = useState<VideoStatus>();
|
|
const [src, setSrc] = useState<string>();
|
|
const [levels, setLevels] = useState<Array<{ level: number; height: number }>>();
|
|
const [level, setLevel] = useState<number>(-1);
|
|
const [playState, setPlayState] = useState<"loading" | "playing" | "paused">("loading");
|
|
const [volume, setVolume] = useState(1);
|
|
const [position, setPosition] = useState<number>();
|
|
const [maxPosition, setMaxPosition] = useState<number>();
|
|
|
|
useEffect(() => {
|
|
if (streamCached && video.current) {
|
|
if (Hls.isSupported()) {
|
|
try {
|
|
const hls = new Hls({
|
|
enableWorker: true,
|
|
lowLatencyMode: true,
|
|
backBufferLength: 90,
|
|
});
|
|
hls.loadSource(streamCached);
|
|
hls.attachMedia(video.current);
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
console.debug(event, data);
|
|
const errorType = data.type;
|
|
if (errorType === Hls.ErrorTypes.NETWORK_ERROR && data.fatal) {
|
|
hls.stopLoad();
|
|
hls.detachMedia();
|
|
setStatus(VideoStatus.Offline);
|
|
}
|
|
});
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
setStatus(VideoStatus.Online);
|
|
setLevels([
|
|
{
|
|
level: -1,
|
|
height: 0,
|
|
},
|
|
...hls.levels.map((a, i) => ({
|
|
level: i,
|
|
height: a.height,
|
|
})),
|
|
]);
|
|
});
|
|
hls.on(Hls.Events.LEVEL_SWITCHING, (_, l) => {
|
|
console.debug("HLS Level Switch", l);
|
|
setMaxPosition(l.details?.totalduration);
|
|
});
|
|
// @ts-ignore Can write anyway
|
|
hlsObj.current = hls;
|
|
return () => {
|
|
// @ts-ignore Can write anyway
|
|
hlsObj.current = null;
|
|
hls.destroy();
|
|
};
|
|
} catch (e) {
|
|
console.error(e);
|
|
setStatus(VideoStatus.Offline);
|
|
}
|
|
} else {
|
|
setSrc(streamCached);
|
|
setStatus(VideoStatus.Online);
|
|
video.current.muted = true;
|
|
video.current.load();
|
|
}
|
|
}
|
|
}, [video, streamCached, props.status]);
|
|
|
|
useEffect(() => {
|
|
if (hlsObj.current) {
|
|
hlsObj.current.nextLevel = level;
|
|
}
|
|
}, [hlsObj, level]);
|
|
|
|
useEffect(() => {
|
|
if (video.current) {
|
|
video.current.onplaying = () => setPlayState("playing");
|
|
video.current.onpause = () => setPlayState("paused");
|
|
video.current.onseeking = () => setPlayState("loading");
|
|
video.current.onplay = () => setPlayState("loading");
|
|
video.current.onvolumechange = () => setVolume(video.current?.volume ?? 1);
|
|
video.current.ontimeupdate = () => setPosition(video.current?.currentTime);
|
|
}
|
|
}, [video]);
|
|
|
|
useEffect(() => {
|
|
if (video.current) {
|
|
video.current.volume = volume;
|
|
}
|
|
}, [video, volume]);
|
|
|
|
function playStateToIcon() {
|
|
switch (playState) {
|
|
case "playing":
|
|
return "pause";
|
|
case "paused":
|
|
return "play";
|
|
case "loading":
|
|
return "loading";
|
|
}
|
|
}
|
|
|
|
function togglePlay() {
|
|
if (video.current) {
|
|
if (playState === "playing") {
|
|
video.current.pause();
|
|
} else if (playState === "paused") {
|
|
video.current.play();
|
|
}
|
|
}
|
|
}
|
|
|
|
function levelName(l: number) {
|
|
if (l === -1) {
|
|
return <FormattedMessage defaultMessage="AUTO" id="o8pHw3" />;
|
|
} else {
|
|
const h = levels?.find(a => a.level === l)?.height;
|
|
return <FormattedMessage defaultMessage="{n}p" id="YagVIe" values={{ n: h }} />;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
{status === VideoStatus.Online && (
|
|
<div
|
|
className="absolute opacity-0 hover:opacity-90 transition-opacity w-full h-full z-20 bg-[#00000055]"
|
|
onClick={() => togglePlay()}>
|
|
<div className="absolute w-full h-full flex items-center justify-center pointer">
|
|
<Icon name={playStateToIcon()} size={80} className={playState === "loading" ? "animate-spin" : ""} />
|
|
</div>
|
|
<div
|
|
className="absolute flex items-center gap-1 bottom-0 w-full bg-primary h-[40px]"
|
|
onClick={e => e.stopPropagation()}>
|
|
<div className="flex grow gap-1 items-center">
|
|
<div className="px-5 py-2 pointer" onClick={() => togglePlay()}>
|
|
<Icon name={playStateToIcon()} className={playState === "loading" ? "animate-spin" : ""} />
|
|
</div>
|
|
<div className="px-3 py-2 uppercase font-bold tracking-wide hover:bg-primary-hover">{props.status}</div>
|
|
{props.status === StreamState.Ended && maxPosition !== undefined && position !== undefined && (
|
|
<ProgressBar
|
|
value={position / maxPosition}
|
|
setValue={v => {
|
|
const ct = maxPosition * v;
|
|
if (video.current) {
|
|
video.current.currentTime = ct;
|
|
}
|
|
setPosition(ct);
|
|
}}
|
|
marker={<div className="w-[16px] h-[16px] mt-[-8px] rounded-full bg-white"></div>}
|
|
style={{ width: "100%", height: "4px" }}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-1 items-center h-full py-2">
|
|
<Icon name="volume" />
|
|
<ProgressBar value={volume} setValue={v => setVolume(v)} style={{ width: "100px", height: "100%" }} />
|
|
</div>
|
|
<div>
|
|
<Menu
|
|
direction="top"
|
|
align="center"
|
|
menuButton={<div className="px-3 py-2 tracking-wide pointer">{levelName(level)}</div>}
|
|
menuClassName="bg-primary w-fit">
|
|
{levels?.map(v => (
|
|
<MenuItem
|
|
value={v.level}
|
|
key={v.level}
|
|
onClick={() => setLevel(v.level)}
|
|
className="bg-primary px-3 py-2 text-white">
|
|
{levelName(v.level)}
|
|
</MenuItem>
|
|
))}
|
|
</Menu>
|
|
</div>
|
|
<div
|
|
className="px-3 py-2 pointer"
|
|
onClick={() => {
|
|
if (video.current) {
|
|
video.current.requestFullscreen();
|
|
}
|
|
}}>
|
|
<Icon name="fullscreen" size={24} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{status === VideoStatus.Offline && (
|
|
<div className="absolute w-full h-full z-20 bg-[#000000aa] flex items-center justify-center text-3xl font-bold uppercase">
|
|
<FormattedMessage defaultMessage="Offline" id="7UOvbT" />
|
|
</div>
|
|
)}
|
|
<video className="z-10" ref={video} autoPlay={true} poster={props.poster} src={src} playsInline={true} />
|
|
</div>
|
|
);
|
|
}
|