forked from Github/frigate
Use react lazy to break js files into smaller chunks & remove videojs in favor of hls.js (#10431)
* Use dynamic imports to reduce initial load times Remove videojs * Convert to using hls.js instead of videojs * Improve mobile controls experience * Cleanup * Ensure playback rate stays teh same when source changes * Use webp for latest camera image * Switch to hls.js on error * Don't rerun error if hls already tried * Fix error checking * also check for media decode error to fallback to HLS --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
321
web/src/components/player/HlsVideoPlayer.tsx
Normal file
321
web/src/components/player/HlsVideoPlayer.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Hls from "hls.js";
|
||||
import { isDesktop, isMobile, isSafari } from "react-device-detect";
|
||||
import { LuPause, LuPlay } from "react-icons/lu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { MdForward10, MdReplay10 } from "react-icons/md";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
|
||||
const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const;
|
||||
const unsupportedErrorCodes = [
|
||||
MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED,
|
||||
MediaError.MEDIA_ERR_DECODE,
|
||||
];
|
||||
|
||||
type HlsVideoPlayerProps = {
|
||||
className: string;
|
||||
children?: ReactNode;
|
||||
videoRef: MutableRefObject<HTMLVideoElement | null>;
|
||||
currentSource: string;
|
||||
onClipEnded?: () => void;
|
||||
onPlayerLoaded?: () => void;
|
||||
onTimeUpdate?: (time: number) => void;
|
||||
};
|
||||
export default function HlsVideoPlayer({
|
||||
className,
|
||||
children,
|
||||
videoRef,
|
||||
currentSource,
|
||||
onClipEnded,
|
||||
onPlayerLoaded,
|
||||
onTimeUpdate,
|
||||
}: HlsVideoPlayerProps) {
|
||||
// playback
|
||||
|
||||
const hlsRef = useRef<Hls>();
|
||||
const [useHlsCompat, setUseHlsCompat] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoRef.current.canPlayType(HLS_MIME_TYPE)) {
|
||||
return;
|
||||
} else if (Hls.isSupported()) {
|
||||
setUseHlsCompat(true);
|
||||
}
|
||||
}, [videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPlaybackRate = videoRef.current.playbackRate;
|
||||
|
||||
if (!useHlsCompat) {
|
||||
videoRef.current.src = currentSource;
|
||||
videoRef.current.load();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hlsRef.current) {
|
||||
hlsRef.current = new Hls();
|
||||
hlsRef.current.attachMedia(videoRef.current);
|
||||
}
|
||||
|
||||
hlsRef.current.loadSource(currentSource);
|
||||
videoRef.current.playbackRate = currentPlaybackRate;
|
||||
}, [videoRef, hlsRef, useHlsCompat, currentSource]);
|
||||
|
||||
// controls
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(true);
|
||||
const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>();
|
||||
const [controls, setControls] = useState(isMobile);
|
||||
const [controlsOpen, setControlsOpen] = useState(false);
|
||||
|
||||
const onKeyboardShortcut = useCallback(
|
||||
(key: string, down: boolean, repeat: boolean) => {
|
||||
if (!videoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "ArrowLeft":
|
||||
if (down) {
|
||||
const currentTime = videoRef.current.currentTime;
|
||||
|
||||
if (currentTime) {
|
||||
videoRef.current.currentTime = Math.max(0, currentTime - 5);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (down) {
|
||||
const currentTime = videoRef.current.currentTime;
|
||||
|
||||
if (currentTime) {
|
||||
videoRef.current.currentTime = currentTime + 5;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "m":
|
||||
if (down && !repeat && videoRef.current) {
|
||||
videoRef.current.muted = !videoRef.current.muted;
|
||||
}
|
||||
break;
|
||||
case " ":
|
||||
if (down && videoRef.current) {
|
||||
if (videoRef.current.paused) {
|
||||
videoRef.current.play();
|
||||
} else {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
// only update when preview only changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[videoRef.current],
|
||||
);
|
||||
useKeyboardListener(
|
||||
["ArrowLeft", "ArrowRight", "m", " "],
|
||||
onKeyboardShortcut,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${className ?? ""}`}
|
||||
onMouseOver={
|
||||
isDesktop
|
||||
? () => {
|
||||
setControls(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseOut={
|
||||
isDesktop
|
||||
? () => {
|
||||
setControls(controlsOpen);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={isDesktop ? undefined : () => setControls(!controls)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="size-full rounded-2xl"
|
||||
preload="auto"
|
||||
autoPlay
|
||||
controls={false}
|
||||
playsInline
|
||||
onPlay={() => {
|
||||
setIsPlaying(true);
|
||||
|
||||
if (isMobile) {
|
||||
setControls(true);
|
||||
setMobileCtrlTimeout(setTimeout(() => setControls(false), 4000));
|
||||
}
|
||||
}}
|
||||
onPause={() => {
|
||||
setIsPlaying(false);
|
||||
|
||||
if (isMobile && mobileCtrlTimeout) {
|
||||
clearTimeout(mobileCtrlTimeout);
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={() =>
|
||||
onTimeUpdate && videoRef.current
|
||||
? onTimeUpdate(videoRef.current.currentTime)
|
||||
: undefined
|
||||
}
|
||||
onLoadedData={onPlayerLoaded}
|
||||
onEnded={onClipEnded}
|
||||
onError={(e) => {
|
||||
if (
|
||||
!hlsRef.current &&
|
||||
// @ts-expect-error code does exist
|
||||
unsupportedErrorCodes.includes(e.target.error.code) &&
|
||||
videoRef.current
|
||||
) {
|
||||
setUseHlsCompat(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<VideoControls
|
||||
video={videoRef.current}
|
||||
isPlaying={isPlaying}
|
||||
show={controls}
|
||||
controlsOpen={controlsOpen}
|
||||
setControlsOpen={setControlsOpen}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type VideoControlsProps = {
|
||||
video: HTMLVideoElement | null;
|
||||
isPlaying: boolean;
|
||||
show: boolean;
|
||||
controlsOpen: boolean;
|
||||
setControlsOpen: (open: boolean) => void;
|
||||
};
|
||||
function VideoControls({
|
||||
video,
|
||||
isPlaying,
|
||||
show,
|
||||
controlsOpen,
|
||||
setControlsOpen,
|
||||
}: VideoControlsProps) {
|
||||
const playbackRates = useMemo(() => {
|
||||
if (isSafari) {
|
||||
return [0.5, 1, 2];
|
||||
} else {
|
||||
return [0.5, 1, 2, 4, 8, 16];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onReplay = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const currentTime = video?.currentTime;
|
||||
|
||||
if (!video || !currentTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.currentTime = Math.max(0, currentTime - 10);
|
||||
},
|
||||
[video],
|
||||
);
|
||||
|
||||
const onSkip = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const currentTime = video?.currentTime;
|
||||
|
||||
if (!video || !currentTime) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.currentTime = currentTime + 10;
|
||||
},
|
||||
[video],
|
||||
);
|
||||
|
||||
const onTogglePlay = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.play();
|
||||
}
|
||||
},
|
||||
[isPlaying, video],
|
||||
);
|
||||
|
||||
if (!video || !show) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute bottom-5 left-1/2 -translate-x-1/2 px-4 py-2 flex justify-between items-center gap-8 text-white z-50 bg-black bg-opacity-60 rounded-lg`}
|
||||
>
|
||||
<MdReplay10 className="size-5 cursor-pointer" onClick={onReplay} />
|
||||
<div className="cursor-pointer" onClick={onTogglePlay}>
|
||||
{isPlaying ? (
|
||||
<LuPause className="size-5 fill-white" />
|
||||
) : (
|
||||
<LuPlay className="size-5 fill-white" />
|
||||
)}
|
||||
</div>
|
||||
<MdForward10 className="size-5 cursor-pointer" onClick={onSkip} />
|
||||
<DropdownMenu
|
||||
open={controlsOpen}
|
||||
onOpenChange={(open) => {
|
||||
setControlsOpen(open);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger>{`${video.playbackRate}x`}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(rate) => (video.playbackRate = parseInt(rate))}
|
||||
>
|
||||
{playbackRates.map((rate) => (
|
||||
<DropdownMenuRadioItem key={rate} value={rate.toString()}>
|
||||
{rate}x
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user