import { useFrigateReviews } from "@/api/ws"; import Logo from "@/components/Logo"; import { CameraGroupSelector } from "@/components/filter/CameraGroupSelector"; import { LiveGridIcon, LiveListIcon } from "@/components/icons/LiveIcons"; import { AnimatedEventCard } from "@/components/card/AnimatedEventCard"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import LivePlayer from "@/components/player/LivePlayer"; import { Button } from "@/components/ui/button"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { usePersistence } from "@/hooks/use-persistence"; import { AllGroupsStreamingSettings, CameraConfig, FrigateConfig, } from "@/types/frigateConfig"; import { ReviewSegment } from "@/types/review"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isDesktop, isMobile, isMobileOnly, isTablet, } from "react-device-detect"; import useSWR from "swr"; import DraggableGridLayout from "./DraggableGridLayout"; import { IoClose } from "react-icons/io5"; import { LuLayoutDashboard } from "react-icons/lu"; import { cn } from "@/lib/utils"; import { AudioState, LivePlayerError, StatsState, VolumeState, } from "@/types/live"; import { FaCompress, FaExpand } from "react-icons/fa"; import useCameraLiveMode from "@/hooks/use-camera-live-mode"; import { useResizeObserver } from "@/hooks/resize-observer"; import LiveContextMenu from "@/components/menu/LiveContextMenu"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useTranslation } from "react-i18next"; type LiveDashboardViewProps = { cameras: CameraConfig[]; cameraGroup: string; includeBirdseye: boolean; onSelectCamera: (camera: string) => void; fullscreen: boolean; toggleFullscreen: () => void; }; export default function LiveDashboardView({ cameras, cameraGroup, includeBirdseye, onSelectCamera, fullscreen, toggleFullscreen, }: LiveDashboardViewProps) { const { t } = useTranslation(["views/live"]); const { data: config } = useSWR("config"); // layout const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">( "live-layout", isDesktop ? "grid" : "list", ); const [isEditMode, setIsEditMode] = useState(false); const containerRef = useRef(null); const birdseyeContainerRef = useRef(null); // recent events const eventUpdate = useFrigateReviews(); const alertCameras = useMemo(() => { if (!config || cameraGroup == "default") { return null; } if (includeBirdseye && cameras.length == 0) { return Object.values(config.cameras) .filter((cam) => cam.birdseye.enabled) .map((cam) => cam.name) .join(","); } return cameras .map((cam) => cam.name) .filter((cam) => config.camera_groups[cameraGroup]?.cameras.includes(cam)) .join(","); }, [cameras, cameraGroup, config, includeBirdseye]); const { data: allEvents, mutate: updateEvents } = useSWR([ "review", { limit: 10, severity: "alert", cameras: alertCameras, }, ]); useEffect(() => { if (!eventUpdate) { return; } // if event is ended and was saved, update events list if (eventUpdate.after.severity == "alert") { if (eventUpdate.type == "end" || eventUpdate.type == "new") { setTimeout( () => updateEvents(), eventUpdate.type == "end" ? 1000 : 6000, ); } else if ( eventUpdate.before.data.objects.length < eventUpdate.after.data.objects.length ) { setTimeout(() => updateEvents(), 5000); } return; } }, [eventUpdate, updateEvents]); const events = useMemo(() => { if (!allEvents) { return []; } const date = new Date(); date.setHours(date.getHours() - 1); const cutoff = date.getTime() / 1000; return allEvents.filter((event) => event.start_time > cutoff); }, [allEvents]); // camera live views const [{ height: containerHeight }] = useResizeObserver(containerRef); const hasScrollbar = useMemo(() => { if (containerHeight && containerRef.current) { return ( containerRef.current.offsetHeight < containerRef.current.scrollHeight ); } }, [containerRef, containerHeight]); const [windowVisible, setWindowVisible] = useState(true); const visibilityListener = useCallback(() => { setWindowVisible(document.visibilityState == "visible"); }, []); useEffect(() => { addEventListener("visibilitychange", visibilityListener); return () => { removeEventListener("visibilitychange", visibilityListener); }; }, [visibilityListener]); const [visibleCameras, setVisibleCameras] = useState([]); const visibleCameraObserver = useRef(null); useEffect(() => { const visibleCameras = new Set(); visibleCameraObserver.current = new IntersectionObserver( (entries) => { entries.forEach((entry) => { const camera = (entry.target as HTMLElement).dataset.camera; if (!camera) { return; } if (entry.isIntersecting) { visibleCameras.add(camera); } else { visibleCameras.delete(camera); } setVisibleCameras([...visibleCameras]); }); }, { threshold: 0.5 }, ); return () => { visibleCameraObserver.current?.disconnect(); }; }, []); const { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode, isRestreamedStates, supportsAudioOutputStates, } = useCameraLiveMode(cameras, windowVisible); const [globalAutoLive] = usePersistence("autoLiveView", true); const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } = useStreamingSettings(); const currentGroupStreamingSettings = useMemo(() => { if (cameraGroup && cameraGroup != "default" && allGroupsStreamingSettings) { return allGroupsStreamingSettings[cameraGroup]; } }, [allGroupsStreamingSettings, cameraGroup]); const cameraRef = useCallback( (node: HTMLElement | null) => { if (!visibleCameraObserver.current) { return; } try { if (node) visibleCameraObserver.current.observe(node); } catch (e) { // no op } }, // we need to listen on the value of the ref // eslint-disable-next-line react-hooks/exhaustive-deps [visibleCameraObserver.current], ); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const handleError = useCallback( (cameraName: string, error: LivePlayerError) => { setPreferredLiveModes((prevModes) => { const newModes = { ...prevModes }; if (error === "mse-decode") { newModes[cameraName] = "webrtc"; } else { newModes[cameraName] = "jsmpeg"; } return newModes; }); }, [setPreferredLiveModes], ); // audio states const [audioStates, setAudioStates] = useState({}); const [volumeStates, setVolumeStates] = useState({}); const [statsStates, setStatsStates] = useState({}); const toggleStats = (cameraName: string): void => { setStatsStates((prev) => ({ ...prev, [cameraName]: !prev[cameraName], })); }; useEffect(() => { if (!allGroupsStreamingSettings) { return; } const initialAudioStates: AudioState = {}; const initialVolumeStates: VolumeState = {}; Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => { if (groupSettings) { Object.entries(groupSettings).forEach(([camera, cameraSettings]) => { initialAudioStates[camera] = cameraSettings.playAudio ?? false; initialVolumeStates[camera] = cameraSettings.volume ?? 1; }); } }); setAudioStates(initialAudioStates); setVolumeStates(initialVolumeStates); }, [allGroupsStreamingSettings]); const toggleAudio = (cameraName: string): void => { setAudioStates((prev) => ({ ...prev, [cameraName]: !prev[cameraName], })); }; const onSaveMuting = useCallback( (playAudio: boolean) => { if ( !cameraGroup || !allGroupsStreamingSettings || cameraGroup == "default" ) { return; } const existingGroupSettings = allGroupsStreamingSettings[cameraGroup] || {}; const updatedSettings: AllGroupsStreamingSettings = { ...Object.fromEntries( Object.entries(allGroupsStreamingSettings || {}).filter( ([key]) => key !== cameraGroup, ), ), [cameraGroup]: { ...existingGroupSettings, ...Object.fromEntries( Object.entries(existingGroupSettings).map( ([cameraName, settings]) => [ cameraName, { ...settings, playAudio: playAudio, }, ], ), ), }, }; setAllGroupsStreamingSettings?.(updatedSettings); }, [cameraGroup, allGroupsStreamingSettings, setAllGroupsStreamingSettings], ); const muteAll = (): void => { const updatedStates: Record = {}; visibleCameras.forEach((cameraName) => { updatedStates[cameraName] = false; }); setAudioStates(updatedStates); onSaveMuting(false); }; const unmuteAll = (): void => { const updatedStates: Record = {}; visibleCameras.forEach((cameraName) => { updatedStates[cameraName] = true; }); setAudioStates(updatedStates); onSaveMuting(true); }; return (
{isMobile && (
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
)} {cameraGroup && cameraGroup !== "default" && isTablet && (
)}
)} {!fullscreen && events && events.length > 0 && (
{events.map((event) => { return ( ); })}
)} {!cameraGroup || cameraGroup == "default" || isMobileOnly ? ( <>
{includeBirdseye && birdseyeConfig?.enabled && (
{ const aspectRatio = birdseyeConfig.width / birdseyeConfig.height; if (aspectRatio > 2) { return `${mobileLayout == "grid" && "col-span-2"} aspect-wide`; } else if (aspectRatio < 1) { return `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`; } else { return "aspect-video"; } })()} ref={birdseyeContainerRef} > onSelectCamera("birdseye")} containerRef={birdseyeContainerRef} />
)} {cameras.map((camera) => { let grow; const aspectRatio = camera.detect.width / camera.detect.height; if (aspectRatio > 2) { grow = `${mobileLayout == "grid" && "col-span-2"} aspect-wide`; } else if (aspectRatio < 1) { grow = `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`; } else { grow = "aspect-video"; } const availableStreams = camera.live.streams || {}; const firstStreamEntry = Object.values(availableStreams)[0] || ""; const streamNameFromSettings = currentGroupStreamingSettings?.[camera.name]?.streamName || ""; const streamExists = streamNameFromSettings && Object.values(availableStreams).includes( streamNameFromSettings, ); const streamName = streamExists ? streamNameFromSettings : firstStreamEntry; const streamType = currentGroupStreamingSettings?.[camera.name]?.streamType; const autoLive = streamType !== undefined ? streamType !== "no-streaming" : undefined; const showStillWithoutActivity = currentGroupStreamingSettings?.[camera.name]?.streamType !== "continuous"; const useWebGL = currentGroupStreamingSettings?.[camera.name] ?.compatibilityMode || false; return ( toggleAudio(camera.name)} statsState={statsStates[camera.name]} toggleStats={() => toggleStats(camera.name)} volumeState={volumeStates[camera.name] ?? 1} setVolumeState={(value) => setVolumeStates({ [camera.name]: value, }) } muteAll={muteAll} unmuteAll={unmuteAll} resetPreferredLiveMode={() => resetPreferredLiveMode(camera.name) } config={config} > onSelectCamera(camera.name)} onError={(e) => handleError(camera.name, e)} onResetLiveMode={() => resetPreferredLiveMode(camera.name)} playAudio={audioStates[camera.name] ?? false} volume={volumeStates[camera.name]} /> ); })}
{isDesktop && (
{fullscreen ? ( ) : ( )}
{fullscreen ? t("button.exitFullscreen", { ns: "common" }) : t("button.fullscreen", { ns: "common" })}
)} ) : ( )}
); }