import { useAudioState, 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 } from "@/types/frigateConfig"; import { 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, isSafari, 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 { HiViewfinderCircle } from "react-icons/hi2"; 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 = { camera: CameraConfig; }; export default function LiveCameraView({ camera }: LiveCameraViewProps) { const navigate = useNavigate(); const { isPortrait } = useMobileOrientation(); const mainRef = useRef(null); const [{ width: windowWidth, height: windowHeight }] = useResizeObserver(window); // 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], ); // fullscreen / pip state useEffect(() => { if (mainRef.current == null) { return; } const fsListener = () => { setFullscreen(document.fullscreenElement != null); }; document.addEventListener("fullscreenchange", fsListener); return () => { document.removeEventListener("fullscreenchange", fsListener); }; }, [mainRef]); 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 [fullscreen, setFullscreen] = useState(false); const [pip, setPip] = useState(false); const [fullResolution, setFullResolution] = useState({ width: 0, height: 0, }); const growClassName = useMemo(() => { let aspect; if (fullResolution.width && fullResolution.height) { aspect = fullResolution.width / fullResolution.height; } else { aspect = camera.detect.width / camera.detect.height; } if (isMobile) { if (isPortrait) { return "absolute left-2 right-2 top-[50%] -translate-y-[50%]"; } else { if (aspect > 1.5) { return "p-2 absolute left-0 top-[50%] -translate-y-[50%]"; } else { return "p-2 absolute top-2 bottom-2 left-[50%] -translate-x-[50%]"; } } } if (fullscreen) { if (aspect > 1.5) { 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-2 bottom-2 left-[50%] -translate-x-[50%]"; } }, [camera, fullscreen, isPortrait, fullResolution]); const preferredLiveMode = useMemo(() => { if (isSafari || mic) { return "webrtc"; } return "mse"; }, [mic]); const windowAspectRatio = useMemo(() => { return windowWidth / windowHeight; }, [windowWidth, windowHeight]); const cameraAspectRatio = useMemo(() => { if (fullResolution.width && fullResolution.height) { return fullResolution.width / fullResolution.height; } else { return camera.detect.width / camera.detect.height; } }, [camera, fullResolution]); const aspectRatio = useMemo(() => { if (isMobile || fullscreen) { return cameraAspectRatio; } else { return windowAspectRatio < cameraAspectRatio ? windowAspectRatio - 0.05 : cameraAspectRatio - 0.03; } }, [cameraAspectRatio, windowAspectRatio, fullscreen]); return (
{!fullscreen ? (
) : (
)}
{!isIOS && ( { if (fullscreen) { document.exitFullscreen(); } else { mainRef.current?.requestFullscreen(); } }} /> )} {!isIOS && ( { if (!pip) { setPip(true); } else { document.exitPictureInPicture(); setPip(false); } }} /> )} {window.isSecureContext && ( setMic(!mic)} /> )} 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; fullscreen: boolean; }; function FrigateCameraFeatures({ camera, audioDetectEnabled, 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); // desktop shows icons part of row if (isDesktop) { return ( <> sendDetect(detectState == "ON" ? "OFF" : "ON")} /> sendRecord(recordState == "ON" ? "OFF" : "ON")} /> sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} /> {audioDetectEnabled && ( sendAudio(audioState == "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")} /> )} ); }