import { useAudioLiveTranscription, useAudioState, useAudioTranscriptionState, 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, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; 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 { RecordingStartingPoint } from "@/types/record"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { isDesktop, isFirefox, isIOS, isMobile, isTablet, useMobileOrientation, } from "react-device-detect"; import { 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 { MdClosedCaption, MdClosedCaptionDisabled, MdNoPhotography, MdOutlineRestartAlt, MdPersonOff, MdPersonSearch, MdPhotoCamera, } 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, SelectValue, } 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"; import { useDocDomain } from "@/hooks/use-doc-domain"; import PtzControlPanel from "@/components/overlay/PtzControlPanel"; import ObjectSettingsView from "../settings/ObjectSettingsView"; import { useSearchEffect } from "@/hooks/use-overlay-state"; 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"; // for audio transcriptions const { payload: audioTranscriptionState, send: sendTranscription } = useAudioTranscriptionState(camera.name); const { payload: transcription } = useAudioLiveTranscription(camera.name); const transcriptionRef = useRef(null); useEffect(() => { if (transcription) { if (transcriptionRef.current) { transcriptionRef.current.scrollTop = transcriptionRef.current.scrollHeight; } } }, [transcription]); useEffect(() => { return () => { // disable transcriptions when unmounting if (audioTranscriptionState == "ON") sendTranscription("OFF"); }; }, [audioTranscriptionState, sendTranscription]); // 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 [debug, setDebug] = useState(false); useSearchEffect("debug", (value: string) => { if (value === "true") { setDebug(true); } return true; }); 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 true; } switch (key) { case "m": if (supportsAudioOutput) { setAudio(!audio); return true; } break; case "t": if (supports2WayTalk) { setMic(!mic); return true; } break; } return false; }); // 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 || debug} /> )} {supports2WayTalk && ( { setMic(!mic); if (!mic && !audio) { setAudio(true); } }} disabled={!cameraEnabled || debug} /> )} {supportsAudioOutput && preferredLiveMode != "jsmpeg" && ( setAudio(!audio)} disabled={!cameraEnabled || debug} /> )}
{!debug ? (
{camera?.audio?.enabled_in_config && audioTranscriptionState == "ON" && transcription != null && (
{transcription}
)}
) : ( )}
{camera.onvif.host != "" && (
)} ); } 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; transcriptionEnabled: 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; debug: boolean; setDebug: (debug: boolean) => void; }; function FrigateCameraFeatures({ camera, recordingEnabled, audioDetectEnabled, autotrackingEnabled, transcriptionEnabled, fullscreen, streamName, setStreamName, preferredLiveMode, playInBackground, setPlayInBackground, showStats, setShowStats, isRestreamed, setLowBandwidth, supportsAudioOutput, supports2WayTalk, cameraEnabled, debug, setDebug, }: FrigateCameraFeaturesProps) { const { t } = useTranslation(["views/live", "components/dialog"]); const { getLocaleDocUrl } = useDocDomain(); 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); const { payload: transcriptionState, send: sendTranscription } = useAudioTranscriptionState(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 }, []); // desktop shows icons part of row if (isDesktop || isTablet) { return ( <> {isAdmin && ( <> sendEnabled(enabledState == "ON" ? "OFF" : "ON")} disabled={debug} /> 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} /> )} {audioDetectEnabled && transcriptionEnabled && ( sendTranscription(transcriptionState == "ON" ? "OFF" : "ON") } disabled={!cameraEnabled || audioState == "OFF"} /> )} {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("readTheDocumentation", { ns: "common" })}
)} {isRestreamed && Object.values(camera.live.streams).length > 0 && (
{debug && (
<>
{t("stream.debug.picker")}
)} {preferredLiveMode != "jsmpeg" && !debug && isRestreamed && (
{supportsAudioOutput ? ( <>
{t("stream.audio.available")}
) : ( <>
{t("stream.audio.unavailable")}
{t("button.info", { ns: "common" })}
{t("stream.audio.tips.title")}
{t("readTheDocumentation", { ns: "common", })}
)}
)} {preferredLiveMode != "jsmpeg" && !debug && isRestreamed && supportsAudioOutput && (
{supports2WayTalk ? ( <>
{t("stream.twoWayTalk.available")}
) : ( <>
{t("stream.twoWayTalk.unavailable")}
{t("button.info", { ns: "common" })}
{t("stream.twoWayTalk.tips")}
{t("readTheDocumentation", { ns: "common", })}
)}
)} {preferredLiveMode == "jsmpeg" && !debug && isRestreamed && (

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

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

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

)}
setShowStats(checked)} />

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

setDebug(checked)} />
); } // 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") } /> )} {audioDetectEnabled && transcriptionEnabled && ( sendTranscription(transcriptionState == "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.title", { ns: "components/dialog", })}
{t("readTheDocumentation", { ns: "common" })}
)} {isRestreamed && Object.values(camera.live.streams).length > 0 && (
{t("stream.title")}
{debug && (
<>
{t("stream.debug.picker")}
)} {preferredLiveMode != "jsmpeg" && !debug && isRestreamed && (
{supportsAudioOutput ? ( <>
{t("stream.audio.available")}
) : ( <>
{t("stream.audio.unavailable")}
{t("button.info", { ns: "common" })}
{t("stream.audio.tips.title")}
{t("readTheDocumentation", { ns: "common" })}
)}
)} {preferredLiveMode != "jsmpeg" && !debug && isRestreamed && supportsAudioOutput && (
{supports2WayTalk ? ( <>
{t("stream.twoWayTalk.available")}
) : ( <>
{t("stream.twoWayTalk.unavailable")}
{t("button.info", { ns: "common" })}
{t("stream.twoWayTalk.tips")}
{t("readTheDocumentation", { ns: "common" })}
)}
)} {preferredLiveMode == "jsmpeg" && isRestreamed && (

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

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

{t("manualRecording.tips")}

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

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

{ setShowStats(checked); }} disabled={debug} />

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

)}
setDebug(checked)} />
); }