import { MutableRefObject, useCallback, useEffect, useRef, useState, } from "react"; import Hls from "hls.js"; import { 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"; import { useOverlayState } from "@/hooks/use-overlay-state"; import { usePersistence } from "@/hooks/use-persistence"; import { cn } from "@/lib/utils"; import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record"; import { useTranslation } from "react-i18next"; import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay"; // Android native hls does not seek correctly const USE_NATIVE_HLS = false; const HLS_MIME_TYPE = "application/vnd.apple.mpegurl" as const; const unsupportedErrorCodes = [ MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED, MediaError.MEDIA_ERR_DECODE, ]; export interface HlsSource { playlist: string; startPosition?: number; } type HlsVideoPlayerProps = { videoRef: MutableRefObject; containerRef?: React.MutableRefObject; visible: boolean; currentSource: HlsSource; hotKeys: boolean; supportsFullscreen: boolean; fullscreen: boolean; frigateControls?: boolean; inpointOffset?: number; onClipEnded?: (currentTime: number) => void; onPlayerLoaded?: () => void; onTimeUpdate?: (time: number) => void; onPlaying?: () => void; onSeekToTime?: (timestamp: number, play?: boolean) => void; setFullResolution?: React.Dispatch>; onUploadFrame?: (playTime: number) => Promise | undefined; toggleFullscreen?: () => void; onError?: (error: RecordingPlayerError) => void; isDetailMode?: boolean; camera?: string; currentTimeOverride?: number; }; export default function HlsVideoPlayer({ videoRef, containerRef, visible, currentSource, hotKeys, supportsFullscreen, fullscreen, frigateControls = true, inpointOffset = 0, onClipEnded, onPlayerLoaded, onTimeUpdate, onPlaying, onSeekToTime, setFullResolution, onUploadFrame, toggleFullscreen, onError, isDetailMode = false, camera, currentTimeOverride, }: HlsVideoPlayerProps) { const { t } = useTranslation("components/player"); const { data: config } = useSWR("config"); // for detail stream context in History const currentTime = currentTimeOverride; // playback const hlsRef = useRef(); const [useHlsCompat, setUseHlsCompat] = useState(false); const [loadedMetadata, setLoadedMetadata] = useState(false); const [bufferTimeout, setBufferTimeout] = useState(); const handleLoadedMetadata = useCallback(() => { setLoadedMetadata(true); if (videoRef.current) { const width = videoRef.current.videoWidth; const height = videoRef.current.videoHeight; if (setFullResolution) { setFullResolution({ width, height, }); } setVideoDimensions({ width, height }); setTallCamera(width / height < ASPECT_VERTICAL_LAYOUT); } }, [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.playlist; videoRef.current.load(); return; } hlsRef.current = new Hls({ maxBufferLength: 10, maxBufferSize: 20 * 1000 * 1000, startPosition: currentSource.startPosition, }); hlsRef.current.attachMedia(videoRef.current); hlsRef.current.loadSource(currentSource.playlist); videoRef.current.playbackRate = currentPlaybackRate; return () => { // we must destroy the hlsRef every time the source changes // so that we can create a new HLS instance with startPosition // set at the optimal point in time if (hlsRef.current) { hlsRef.current.destroy(); } }; }, [videoRef, hlsRef, useHlsCompat, currentSource]); // state handling const onPlayPause = useCallback( (play: boolean) => { if (!videoRef.current) { return; } if (play) { videoRef.current.play(); } else { videoRef.current.pause(); } }, [videoRef], ); // controls const [tallCamera, setTallCamera] = useState(false); const [isPlaying, setIsPlaying] = useState(true); const [muted, setMuted] = usePersistence("hlsPlayerMuted", true); const [volume, setVolume] = useOverlayState("playerVolume", 1.0); const [defaultPlaybackRate] = usePersistence("playbackRate", 1); const [playbackRate, setPlaybackRate] = useOverlayState( "playbackRate", defaultPlaybackRate ?? 1, ); const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState(); const [controls, setControls] = useState(isMobile); const [controlsOpen, setControlsOpen] = useState(false); const [zoomScale, setZoomScale] = useState(1.0); const [videoDimensions, setVideoDimensions] = useState<{ width: number; height: number; }>({ width: 0, height: 0 }); 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]); const getVideoTime = useCallback(() => { const currentTime = videoRef.current?.currentTime; if (!currentTime) { return undefined; } return currentTime + inpointOffset; }, [videoRef, inpointOffset]); return ( setZoomScale(zoom.state.scale)} disabled={!frigateControls} > {frigateControls && ( setMuted(muted)} playbackRate={playbackRate ?? 1} hotKeys={hotKeys} onPlayPause={onPlayPause} onSeek={(diff) => { const currentTime = videoRef.current?.currentTime; if (!videoRef.current || !currentTime) { return; } videoRef.current.currentTime = Math.max(0, currentTime + diff); }} onSetPlaybackRate={(rate) => { setPlaybackRate(rate, true); if (videoRef.current) { videoRef.current.playbackRate = rate; } }} onUploadFrame={async () => { const frameTime = getVideoTime(); if (frameTime && onUploadFrame) { const resp = await onUploadFrame(frameTime); if (resp && resp.status == 200) { toast.success(t("toast.success.submittedFrigatePlus"), { position: "top-center", }); } else { toast.success(t("toast.error.submitFrigatePlusFailed"), { position: "top-center", }); } } }} fullscreen={fullscreen} toggleFullscreen={toggleFullscreen} containerRef={containerRef} /> )} setControls(!controls), }} contentStyle={{ width: "100%", height: isMobile ? "100%" : undefined, }} > {isDetailMode && camera && currentTime && loadedMetadata && videoDimensions.width > 0 && videoDimensions.height > 0 && (
{ if (onSeekToTime) { onSeekToTime(timestamp, play); } }} />
)}
); }