import { useAudioState, useAutotrackingState, useDetectState, useEnabledState, 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 { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { useResizeObserver } from "@/hooks/resize-observer"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { LivePlayerError, LiveStreamMetadata, VideoResolutionType, } from "@/types/live"; import { CameraPtzInfo } from "@/types/ptz"; import { RecordingStartingPoint } from "@/types/record"; import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { isDesktop, isFirefox, 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 { TbRecordMail, TbRecordMailOff, TbViewfinder, TbViewfinderOff, } from "react-icons/tb"; import { IoIosWarning, IoMdArrowRoundBack } from "react-icons/io"; import { LuCheck, LuEar, LuEarOff, LuExternalLink, LuHistory, LuInfo, LuPictureInPicture, LuPower, LuPowerOff, LuVideo, LuVideoOff, LuX, } from "react-icons/lu"; import { MdNoPhotography, MdOutlineRestartAlt, MdPersonOff, MdPersonSearch, MdPhotoCamera, MdZoomIn, MdZoomOut, } from "react-icons/md"; import { Link, useNavigate } from "react-router-dom"; import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; import useSWR from "swr"; import { cn } from "@/lib/utils"; import { useSessionPersistence } from "@/hooks/use-session-persistence"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, } from "@/components/ui/select"; import { usePersistence } from "@/hooks/use-persistence"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import axios from "axios"; import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { Trans, useTranslation } from "react-i18next"; type LiveCameraViewProps = { config?: FrigateConfig; camera: CameraConfig; supportsFullscreen: boolean; fullscreen: boolean; toggleFullscreen: () => void; }; export default function LiveCameraView({ config, camera, supportsFullscreen, fullscreen, toggleFullscreen, }: LiveCameraViewProps) { const { t } = useTranslation(["views/live", "components/dialog"]); const navigate = useNavigate(); const { isPortrait } = useMobileOrientation(); const mainRef = useRef(null); const containerRef = useRef(null); const [{ width: windowWidth, height: windowHeight }] = useResizeObserver(window); // supported features const [streamName, setStreamName] = usePersistence( `${camera.name}-stream`, Object.values(camera.live.streams)[0], ); const isRestreamed = useMemo( () => config && Object.keys(config.go2rtc.streams || {}).includes(streamName ?? ""), [config, streamName], ); const { data: cameraMetadata } = useSWR( isRestreamed ? `go2rtc/streams/${streamName}` : 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]); // camera enabled state const { payload: enabledState } = useEnabledState(camera.name); const cameraEnabled = enabledState === "ON"; // 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] = useSessionPersistence("liveAudio", false); const [mic, setMic] = useState(false); const [webRTC, setWebRTC] = useState(false); const [pip, setPip] = useState(false); const [lowBandwidth, setLowBandwidth] = useState(false); const [playInBackground, setPlayInBackground] = usePersistence( `${camera.name}-background-play`, false, ); const [showStats, setShowStats] = useState(false); const [fullResolution, setFullResolution] = useState({ width: 0, height: 0, }); const preferredLiveMode = useMemo(() => { if (mic) { return "webrtc"; } if (webRTC && isRestreamed) { return "webrtc"; } if (webRTC && !isRestreamed) { return "jsmpeg"; } if (lowBandwidth) { return "jsmpeg"; } if (!("MediaSource" in window || "ManagedMediaSource" in window)) { return "webrtc"; } if (!isRestreamed) { return "jsmpeg"; } return "mse"; }, [lowBandwidth, mic, webRTC, isRestreamed]); useKeyboardListener(["m"], (key, modifiers) => { if (!modifiers.down) { return; } switch (key) { case "m": if (supportsAudioOutput) { setAudio(!audio); } break; case "t": if (supports2WayTalk) { setMic(!mic); } break; } }); // layout state 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]); // On mobile devices that support it, try to orient screen // to best fit the camera feed in fullscreen mode useEffect(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const screenOrientation = screen.orientation as any; if (!screenOrientation.lock || !screenOrientation.unlock) { // Browser does not support ScreenOrientation APIs that we need return; } if (fullscreen) { const orientationForBestFit = cameraAspectRatio > 1 ? "landscape" : "portrait"; // If the current device doesn't support locking orientation, // this promise will reject with an error that we can ignore screenOrientation.lock(orientationForBestFit).catch(() => {}); } return () => screenOrientation.unlock(); }, [fullscreen, cameraAspectRatio]); const handleError = useCallback( (e: LivePlayerError) => { if (e) { if ( !webRTC && config && config.go2rtc?.webrtc?.candidates?.length > 0 ) { setWebRTC(true); } else { setWebRTC(false); setLowBandwidth(true); } } }, [config, webRTC], ); return (
{!fullscreen ? (
) : (
)}
{fullscreen && ( )} {supportsFullscreen && ( )} {!isIOS && !isFirefox && preferredLiveMode != "jsmpeg" && ( { if (!pip) { setPip(true); } else { document.exitPictureInPicture(); setPip(false); } }} disabled={!cameraEnabled} /> )} {supports2WayTalk && ( { setMic(!mic); if (!mic && !audio) { setAudio(true); } }} disabled={!cameraEnabled} /> )} {supportsAudioOutput && preferredLiveMode != "jsmpeg" && ( setAudio(!audio)} disabled={!cameraEnabled} /> )}
{camera.onvif.host != "" && (
)} ); } type TooltipButtonProps = { label: string; onClick?: () => void; onMouseDown?: (e: React.MouseEvent) => void; onMouseUp?: (e: React.MouseEvent) => void; onTouchStart?: (e: React.TouchEvent) => void; onTouchEnd?: (e: React.TouchEvent) => void; children: ReactNode; className?: string; }; function TooltipButton({ label, onClick, onMouseDown, onMouseUp, onTouchStart, onTouchEnd, children, className, ...props }: TooltipButtonProps) { return (

{label}

); } function PtzControlPanel({ camera, clickOverlay, setClickOverlay, }: { camera: string; clickOverlay: boolean; setClickOverlay: React.Dispatch>; }) { const { t } = useTranslation(["views/live"]); 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", "+", "-", "1", "2", "3", "4", "5", "6", "7", "8", "9", ], (key, modifiers) => { if (modifiers.repeat || !key) { return; } if (["1", "2", "3", "4", "5", "6", "7", "8", "9"].includes(key)) { const presetNumber = parseInt(key); if ( ptz && (ptz.presets?.length ?? 0) > 0 && presetNumber <= ptz.presets.length ) { sendPtz(`preset_${ptz.presets[presetNumber - 1]}`); } return; } if (!modifiers.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") && ( <> { e.preventDefault(); sendPtz("MOVE_LEFT"); }} onTouchStart={(e) => { e.preventDefault(); sendPtz("MOVE_LEFT"); }} onMouseUp={onStop} onTouchEnd={onStop} > { e.preventDefault(); sendPtz("MOVE_UP"); }} onTouchStart={(e) => { e.preventDefault(); sendPtz("MOVE_UP"); }} onMouseUp={onStop} onTouchEnd={onStop} > { e.preventDefault(); sendPtz("MOVE_DOWN"); }} onTouchStart={(e) => { e.preventDefault(); sendPtz("MOVE_DOWN"); }} onMouseUp={onStop} onTouchEnd={onStop} > { e.preventDefault(); sendPtz("MOVE_RIGHT"); }} onTouchStart={(e) => { e.preventDefault(); sendPtz("MOVE_RIGHT"); }} onMouseUp={onStop} onTouchEnd={onStop} > )} {ptz?.features?.includes("zoom") && ( <> { e.preventDefault(); sendPtz("ZOOM_IN"); }} onTouchStart={(e) => { e.preventDefault(); sendPtz("ZOOM_IN"); }} onMouseUp={onStop} onTouchEnd={onStop} > { e.preventDefault(); sendPtz("ZOOM_OUT"); }} onTouchStart={(e) => { e.preventDefault(); sendPtz("ZOOM_OUT"); }} onMouseUp={onStop} onTouchEnd={onStop} > )} {ptz?.features?.includes("pt-r-fov") && (

{clickOverlay ? t("ptz.move.clickMove.disable") : t("ptz.move.clickMove.enable")}{" "} click to move

)} {(ptz?.presets?.length ?? 0) > 0 && ( e.preventDefault()} > {ptz?.presets.map((preset) => ( sendPtz(`preset_${preset}`)} > {preset} ))}

{t("ptz.presets")}

)}
); } function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) { const { t } = useTranslation(["views/live", "views/events"]); const rankMap = { all: 0, motion: 1, active_objects: 2 }; const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => { const mode = retain?.mode; return mode && mode in rankMap ? (mode as keyof typeof rankMap) : "all"; }; const recordRetainMode = getValidMode(camera.record.retain); const alertsRetainMode = getValidMode(camera.review.alerts.retain); const effectiveRetainMode = rankMap[alertsRetainMode] < rankMap[recordRetainMode] ? recordRetainMode : alertsRetainMode; const source = effectiveRetainMode === recordRetainMode ? "camera" : "alerts"; return effectiveRetainMode !== "all" ? (
effectiveRetainMode.notAllTips
) : null; } type FrigateCameraFeaturesProps = { camera: CameraConfig; recordingEnabled: boolean; audioDetectEnabled: boolean; autotrackingEnabled: boolean; fullscreen: boolean; streamName: string; setStreamName?: (value: string | undefined) => void; preferredLiveMode: string; playInBackground: boolean; setPlayInBackground: (value: boolean | undefined) => void; showStats: boolean; setShowStats: (value: boolean) => void; isRestreamed: boolean; setLowBandwidth: React.Dispatch>; supportsAudioOutput: boolean; supports2WayTalk: boolean; cameraEnabled: boolean; }; function FrigateCameraFeatures({ camera, recordingEnabled, audioDetectEnabled, autotrackingEnabled, fullscreen, streamName, setStreamName, preferredLiveMode, playInBackground, setPlayInBackground, showStats, setShowStats, isRestreamed, setLowBandwidth, supportsAudioOutput, supports2WayTalk, cameraEnabled, }: FrigateCameraFeaturesProps) { const { t } = useTranslation(["views/live", "components/dialog"]); const { payload: detectState, send: sendDetect } = useDetectState( camera.name, ); const { payload: enabledState, send: sendEnabled } = useEnabledState( camera.name, ); const { payload: recordState, send: sendRecord } = useRecordingsState( camera.name, ); const { payload: snapshotState, send: sendSnapshot } = useSnapshotsState( camera.name, ); const { payload: audioState, send: sendAudio } = useAudioState(camera.name); const { payload: autotrackingState, send: sendAutotracking } = useAutotrackingState(camera.name); // roles const isAdmin = useIsAdmin(); // manual event const recordingEventIdRef = useRef(null); const [isRecording, setIsRecording] = useState(false); const [activeToastId, setActiveToastId] = useState( null, ); const createEvent = useCallback(async () => { try { const response = await axios.post( `events/${camera.name}/on_demand/create`, { include_recording: true, duration: null, }, ); if (response.data.success) { recordingEventIdRef.current = response.data.event_id; setIsRecording(true); const toastId = toast.success(
{t("manualRecording.started")}
{!camera.record.enabled || camera.record.alerts.retain.days == 0 ? (
{t("manualRecording.recordDisabledTips")}
) : ( )}
, { position: "top-center", duration: 10000, }, ); setActiveToastId(toastId); } } catch (error) { toast.error(t("manualRecording.failedToStart"), { position: "top-center", }); } }, [camera, t]); const endEvent = useCallback(() => { if (activeToastId) { toast.dismiss(activeToastId); } try { if (recordingEventIdRef.current) { axios.put(`events/${recordingEventIdRef.current}/end`, { end_time: Math.ceil(Date.now() / 1000), }); recordingEventIdRef.current = null; setIsRecording(false); toast.success(t("manualRecording.ended"), { position: "top-center", }); } } catch (error) { toast.error(t("manualRecording.failedToEnd"), { position: "top-center", }); } }, [activeToastId, t]); const handleEventButtonClick = useCallback(() => { if (isRecording) { endEvent(); } else { createEvent(); } }, [createEvent, endEvent, isRecording]); useEffect(() => { // ensure manual event is stopped when component unmounts return () => { if (recordingEventIdRef.current) { endEvent(); } }; // mount/unmount only // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // navigate for debug view const navigate = useNavigate(); // desktop shows icons part of row if (isDesktop || isTablet) { return ( <> {isAdmin && ( <> sendEnabled(enabledState == "ON" ? "OFF" : "ON")} disabled={false} /> sendDetect(detectState == "ON" ? "OFF" : "ON")} disabled={!cameraEnabled} /> sendRecord(recordState == "ON" ? "OFF" : "ON")} disabled={!cameraEnabled} /> sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")} disabled={!cameraEnabled} /> {audioDetectEnabled && ( sendAudio(audioState == "ON" ? "OFF" : "ON")} disabled={!cameraEnabled} /> )} {autotrackingEnabled && ( sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") } disabled={!cameraEnabled} /> )} )}
{!isRestreamed && (
{t("streaming.restreaming.disabled", { ns: "components/dialog", })}
{t("button.info", { ns: "common" })}
{t("streaming.restreaming.desc.title", { ns: "components/dialog", })}
{t( "streaming.restreaming.desc.readTheDocumentation", { ns: "components/dialog", }, )}
)} {isRestreamed && Object.values(camera.live.streams).length > 0 && (
{preferredLiveMode != "jsmpeg" && isRestreamed && (
{supportsAudioOutput ? ( <>
{t("stream.audio.available")}
) : ( <>
{t("stream.audio.unavailable")}
{t("button.info", { ns: "common" })}
{t("stream.audio.tips.title")}
{t("stream.audio.tips.documentation")}
)}
)} {preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudioOutput && (
{supports2WayTalk ? ( <>
{t("stream.twoWayTalk.available")}
) : ( <>
{t("stream.twoWayTalk.unavailable")}
{t("button.info", { ns: "common" })}
{t("stream.twoWayTalk.tips")}
{t( "stream.twoWayTalk.tips.documentation", )}
)}
)} {preferredLiveMode == "jsmpeg" && isRestreamed && (

{t("stream.lowBandwidth.tips")}

)}
)} {isRestreamed && (
setPlayInBackground(checked) } />

{t("stream.playInBackground.tips")}

)}
setShowStats(checked)} />

{t("streaming.showStats.desc", { ns: "components/dialog", })}

navigate(`/settings?page=debug&camera=${camera.name}`) } >
{t("streaming.debugView", { ns: "components/dialog", })}
); } // mobile doesn't show settings in fullscreen view if (fullscreen) { return; } return (
{isAdmin && ( <> sendEnabled(enabledState == "ON" ? "OFF" : "ON") } /> sendDetect(detectState == "ON" ? "OFF" : "ON") } /> {recordingEnabled && ( sendRecord(recordState == "ON" ? "OFF" : "ON") } /> )} sendSnapshot(snapshotState == "ON" ? "OFF" : "ON") } /> {audioDetectEnabled && ( sendAudio(audioState == "ON" ? "OFF" : "ON") } /> )} {autotrackingEnabled && ( sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON") } /> )} )}
{!isRestreamed && (
{t("streaming.restreaming.disabled", { ns: "components/dialog", })}
{t("button.info", { ns: "common" })}
{t("streaming.restreaming.desc", { ns: "components/dialog", })}
{t("streaming.restreaming.readTheDocumentation", { ns: "components/dialog", })}
)} {isRestreamed && Object.values(camera.live.streams).length > 0 && (
{t("stream.title")}
{preferredLiveMode != "jsmpeg" && isRestreamed && (
{supportsAudioOutput ? ( <>
{t("stream.audio.available")}
) : ( <>
{t("stream.audio.unavailable")}
{t("button.info", { ns: "common" })}
{t("stream.audio.tips.title")}
{t("stream.audio.tips.documentation")}
)}
)} {preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudioOutput && (
{supports2WayTalk ? ( <>
{t("stream.twoWayTalk.available")}
) : ( <>
{t("stream.twoWayTalk.unavailable")}
{t("button.info", { ns: "common" })}
{t("stream.twoWayTalk.tips")}
{t("stream.twoWayTalk.tips.documentation")}
)}
)} {preferredLiveMode == "jsmpeg" && isRestreamed && (

{t("stream.lowBandwidth.tips")}

)}
)}
{t("manualRecording.title")}

{t("manualRecording.tips")}

{isRestreamed && ( <>
{ setPlayInBackground(checked); }} />

{t("manualRecording.playInBackground.desc")}

{ setShowStats(checked); }} />

{t("manualRecording.showStats.desc")}

)}
{t("manualRecording.debugView")} navigate(`/settings?page=debug&camera=${camera.name}`) } className="ml-2 inline-flex size-5 cursor-pointer" />
); }