import { MutableRefObject, useCallback, useEffect, useRef, useState, } from "react"; import Hls from "hls.js"; import { isAndroid, isDesktop, isMobile } from "react-device-detect"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import VideoControls from "./VideoControls"; import { VideoResolutionType } from "@/types/live"; import useSWR from "swr"; import { FrigateConfig } from "@/types/frigateConfig"; import { AxiosResponse } from "axios"; import { toast } from "sonner"; // Android native hls does not seek correctly const USE_NATIVE_HLS = !isAndroid; const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const; const unsupportedErrorCodes = [ MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, MediaError.MEDIA_ERR_DECODE, ]; type HlsVideoPlayerProps = { videoRef: MutableRefObject; visible: boolean; currentSource: string; hotKeys: boolean; onClipEnded?: () => void; onPlayerLoaded?: () => void; onTimeUpdate?: (time: number) => void; onPlaying?: () => void; setFullResolution?: React.Dispatch>; onUploadFrame?: (playTime: number) => Promise | undefined; }; export default function HlsVideoPlayer({ videoRef, visible, currentSource, hotKeys, onClipEnded, onPlayerLoaded, onTimeUpdate, onPlaying, setFullResolution, onUploadFrame, }: HlsVideoPlayerProps) { const { data: config } = useSWR("config"); // playback const hlsRef = useRef(); const [useHlsCompat, setUseHlsCompat] = useState(false); const [loadedMetadata, setLoadedMetadata] = useState(false); const handleLoadedMetadata = useCallback(() => { setLoadedMetadata(true); if (videoRef.current) { if (setFullResolution) { setFullResolution({ width: videoRef.current.videoWidth, height: videoRef.current.videoHeight, }); } } }, [videoRef, setFullResolution]); useEffect(() => { if (!videoRef.current) { return; } if (USE_NATIVE_HLS && 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 [muted, setMuted] = useState(true); const [volume, setVolume] = useState(1.0); const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState(); const [controls, setControls] = useState(isMobile); const [controlsOpen, setControlsOpen] = useState(false); useEffect(() => { if (!isDesktop) { return; } const callback = (e: MouseEvent) => { if (!videoRef.current) { return; } const rect = videoRef.current.getBoundingClientRect(); if ( e.clientX > rect.left && e.clientX < rect.right && e.clientY > rect.top && e.clientY < rect.bottom ) { setControls(true); } else { setControls(controlsOpen); } }; window.addEventListener("mousemove", callback); return () => { window.removeEventListener("mousemove", callback); }; }, [videoRef, controlsOpen]); return ( { if (!videoRef.current) { return; } if (play) { videoRef.current.play(); } else { videoRef.current.pause(); } }} onSeek={(diff) => { const currentTime = videoRef.current?.currentTime; if (!videoRef.current || !currentTime) { return; } videoRef.current.currentTime = Math.max(0, currentTime + diff); }} onSetPlaybackRate={(rate) => videoRef.current ? (videoRef.current.playbackRate = rate) : null } onUploadFrame={async () => { if (videoRef.current && onUploadFrame) { const resp = await onUploadFrame(videoRef.current.currentTime); if (resp && resp.status == 200) { toast.success("Successfully submitted frame to Frigate Plus", { position: "top-center", }); } else { toast.success("Failed to submit frame to Frigate Plus", { position: "top-center", }); } } }} /> setControls(!controls), }} contentStyle={{ width: "100%", height: isMobile ? "100%" : undefined, }} > ); }