import { useAudioState, useAutotrackingState, useDetectState, usePtzCommand, useRecordingsState, useSnapshotsState, } from "@/api/ws"; import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle"; import FilterSwitch from "@/components/filter/FilterSwitch"; import LivePlayer from "@/components/player/LivePlayer"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { TooltipProvider } from "@/components/ui/tooltip"; import { useResizeObserver } from "@/hooks/resize-observer"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { LiveStreamMetadata, VideoResolutionType } from "@/types/live"; import { CameraPtzInfo } from "@/types/ptz"; import { RecordingStartingPoint } from "@/types/record"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { isDesktop, isIOS, isMobile, isTablet, useMobileOrientation, } from "react-device-detect"; import { BsThreeDotsVertical } from "react-icons/bs"; import { FaAngleDown, FaAngleLeft, FaAngleRight, FaAngleUp, FaCog, FaCompress, FaExpand, FaMicrophone, FaMicrophoneSlash, } from "react-icons/fa"; import { GiSpeaker, GiSpeakerOff } from "react-icons/gi"; import { TbViewfinder, TbViewfinderOff } from "react-icons/tb"; import { IoMdArrowRoundBack } from "react-icons/io"; import { LuEar, LuEarOff, LuHistory, LuPictureInPicture, LuVideo, LuVideoOff, } from "react-icons/lu"; import { MdNoPhotography, MdPersonOff, MdPersonSearch, MdPhotoCamera, MdZoomIn, MdZoomOut, } from "react-icons/md"; import { useNavigate } from "react-router-dom"; import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; import useSWR from "swr"; type LiveCameraViewProps = { config?: FrigateConfig; camera: CameraConfig; fullscreen: boolean; toggleFullscreen: () => void; }; export default function LiveCameraView({ config, camera, fullscreen, toggleFullscreen, }: LiveCameraViewProps) { const navigate = useNavigate(); const { isPortrait } = useMobileOrientation(); const mainRef = useRef(null); const containerRef = useRef(null); const [{ width: windowWidth, height: windowHeight }] = useResizeObserver(window); // supported features const isRestreamed = useMemo( () => config && Object.keys(config.go2rtc.streams || {}).includes( camera.live.stream_name, ), [camera, config], ); const { data: cameraMetadata } = useSWR( isRestreamed ? `go2rtc/streams/${camera.live.stream_name}` : null, { revalidateOnFocus: false, }, ); const supports2WayTalk = useMemo(() => { if (!window.isSecureContext || !cameraMetadata) { return false; } return ( cameraMetadata.producers.find( (prod) => prod.medias && prod.medias.find((media) => media.includes("audio, sendonly")) != undefined, ) != undefined ); }, [cameraMetadata]); const supportsAudioOutput = useMemo(() => { if (!cameraMetadata) { return false; } return ( cameraMetadata.producers.find( (prod) => prod.medias && prod.medias.find((media) => media.includes("audio, recvonly")) != undefined, ) != undefined ); }, [cameraMetadata]); // click overlay for ptzs const [clickOverlay, setClickOverlay] = useState(false); const clickOverlayRef = useRef(null); const { send: sendPtz } = usePtzCommand(camera.name); const handleOverlayClick = useCallback( ( e: React.MouseEvent | React.TouchEvent, ) => { if (!clickOverlay) { return; } let clientX; let clientY; if ("TouchEvent" in window && e.nativeEvent instanceof TouchEvent) { clientX = e.nativeEvent.touches[0].clientX; clientY = e.nativeEvent.touches[0].clientY; } else if (e.nativeEvent instanceof MouseEvent) { clientX = e.nativeEvent.clientX; clientY = e.nativeEvent.clientY; } if (clickOverlayRef.current && clientX && clientY) { const rect = clickOverlayRef.current.getBoundingClientRect(); const normalizedX = (clientX - rect.left) / rect.width; const normalizedY = (clientY - rect.top) / rect.height; const pan = (normalizedX - 0.5) * 2; const tilt = (0.5 - normalizedY) * 2; sendPtz(`move_relative_${pan}_${tilt}`); } }, [clickOverlayRef, clickOverlay, sendPtz], ); // pip state useEffect(() => { setPip(document.pictureInPictureElement != null); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps }, [document.pictureInPictureElement]); // playback state const [audio, setAudio] = useState(false); const [mic, setMic] = useState(false); const [pip, setPip] = useState(false); const [fullResolution, setFullResolution] = useState({ width: 0, height: 0, }); const preferredLiveMode = useMemo(() => { if (mic) { return "webrtc"; } return "mse"; }, [mic]); const windowAspectRatio = useMemo(() => { return windowWidth / windowHeight; }, [windowWidth, windowHeight]); const containerAspectRatio = useMemo(() => { if (!containerRef.current) { return windowAspectRatio; } return containerRef.current.clientWidth / containerRef.current.clientHeight; }, [windowAspectRatio, containerRef]); const cameraAspectRatio = useMemo(() => { if (fullResolution.width && fullResolution.height) { return fullResolution.width / fullResolution.height; } else { return camera.detect.width / camera.detect.height; } }, [camera, fullResolution]); const constrainedAspectRatio = useMemo(() => { if (isMobile || fullscreen) { return cameraAspectRatio; } else { return containerAspectRatio < cameraAspectRatio ? containerAspectRatio : cameraAspectRatio; } }, [cameraAspectRatio, containerAspectRatio, fullscreen]); const growClassName = useMemo(() => { if (isMobile) { if (isPortrait) { return "absolute left-0.5 right-0.5 top-[50%] -translate-y-[50%]"; } else { if (cameraAspectRatio > containerAspectRatio) { return "p-2 absolute left-0 top-[50%] -translate-y-[50%]"; } else { return "p-2 absolute top-0.5 bottom-0.5 left-[50%] -translate-x-[50%]"; } } } if (fullscreen) { if (cameraAspectRatio > containerAspectRatio) { return "absolute inset-x-2 top-[50%] -translate-y-[50%]"; } else { return "absolute inset-y-2 left-[50%] -translate-x-[50%]"; } } else { return "absolute top-0.5 bottom-0.5 left-[50%] -translate-x-[50%]"; } }, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]); return (
{!fullscreen ? (
) : (
)}
{fullscreen && ( )} {!isIOS && ( )} {!isIOS && ( { if (!pip) { setPip(true); } else { document.exitPictureInPicture(); setPip(false); } }} /> )} {supports2WayTalk && ( setMic(!mic)} /> )} {supportsAudioOutput && ( setAudio(!audio)} /> )}
{camera.onvif.host != "" && ( )}
); } function PtzControlPanel({ camera, clickOverlay, setClickOverlay, }: { camera: string; clickOverlay: boolean; setClickOverlay: React.Dispatch>; }) { const { data: ptz } = useSWR(`${camera}/ptz/info`); const { send: sendPtz } = usePtzCommand(camera); const onStop = useCallback( (e: React.SyntheticEvent) => { e.preventDefault(); sendPtz("STOP"); }, [sendPtz], ); useKeyboardListener( ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "-"], (key, down, repeat) => { if (repeat) { return; } if (!down) { sendPtz("STOP"); return; } switch (key) { case "ArrowLeft": sendPtz("MOVE_LEFT"); break; case "ArrowRight": sendPtz("MOVE_RIGHT"); break; case "ArrowUp": sendPtz("MOVE_UP"); break; case "ArrowDown": sendPtz("MOVE_DOWN"); break; case "+": sendPtz("ZOOM_IN"); break; case "-": sendPtz("ZOOM_OUT"); break; } }, ); return (
{ptz?.features?.includes("pt") && ( <> )} {ptz?.features?.includes("zoom") && ( <> )} {ptz?.features?.includes("pt-r-fov") && ( <> )} {(ptz?.presets?.length ?? 0) > 0 && ( {ptz?.presets.map((preset) => { return ( sendPtz(`preset_${preset}`)} > {preset} ); })} )}
); } type FrigateCameraFeaturesProps = { camera: string; audioDetectEnabled: boolean; autotrackingEnabled: boolean; fullscreen: boolean; }; function FrigateCameraFeatures({ camera, audioDetectEnabled, autotrackingEnabled, fullscreen, }: FrigateCameraFeaturesProps) { const { payload: detectState, send: sendDetect } = useDetectState(camera); const { payload: recordState, send: sendRecord } = useRecordingsState(camera); const { payload: snapshotState, send: sendSnapshot } = useSnapshotsState(camera); const { payload: audioState, send: sendAudio } = useAudioState(camera); const { payload: autotrackingState, send: sendAutotracking } = useAutotrackingState(camera); // desktop shows icons part of row if (isDesktop || isTablet) { return ( <> sendDetect(detectState == "ON" ? "OFF" : "ON")} /> sendRecord(recordState == "ON" ? "OFF" : "ON")} /> sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} /> {audioDetectEnabled && ( sendAudio(audioState == "ON" ? "OFF" : "ON")} /> )} {autotrackingEnabled && ( sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") } /> )} ); } // mobile doesn't show settings in fullscreen view if (fullscreen) { return; } return ( sendDetect(detectState == "ON" ? "OFF" : "ON")} /> sendRecord(recordState == "ON" ? "OFF" : "ON")} /> sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") } /> {audioDetectEnabled && ( sendAudio(audioState == "ON" ? "OFF" : "ON")} /> )} {autotrackingEnabled && ( sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") } /> )} ); }