import { usePersistence } from "@/hooks/use-persistence"; import { AllGroupsStreamingSettings, BirdseyeConfig, CameraConfig, FrigateConfig, } from "@/types/frigateConfig"; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { ItemCallback, Layout, Responsive, WidthProvider, } from "react-grid-layout"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; import { AudioState, LivePlayerMode, StatsState, VolumeState, } from "@/types/live"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { Skeleton } from "@/components/ui/skeleton"; import { useResizeObserver } from "@/hooks/resize-observer"; import { isEqual } from "lodash"; import useSWR from "swr"; import { isDesktop, isMobile } from "react-device-detect"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import LivePlayer from "@/components/player/LivePlayer"; import { IoClose } from "react-icons/io5"; import { LuLayoutDashboard, LuPencil } from "react-icons/lu"; import { cn } from "@/lib/utils"; import { EditGroupDialog } from "@/components/filter/CameraGroupSelector"; import { usePersistedOverlayState } from "@/hooks/use-overlay-state"; import { FaCompress, FaExpand } from "react-icons/fa"; import { Tooltip, TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip"; import { Toaster } from "@/components/ui/sonner"; import useCameraLiveMode from "@/hooks/use-camera-live-mode"; import LiveContextMenu from "@/components/menu/LiveContextMenu"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useTranslation } from "react-i18next"; type DraggableGridLayoutProps = { cameras: CameraConfig[]; cameraGroup: string; cameraRef: (node: HTMLElement | null) => void; containerRef: React.RefObject; includeBirdseye: boolean; onSelectCamera: (camera: string) => void; windowVisible: boolean; visibleCameras: string[]; isEditMode: boolean; setIsEditMode: React.Dispatch>; fullscreen: boolean; toggleFullscreen: () => void; }; export default function DraggableGridLayout({ cameras, cameraGroup, containerRef, cameraRef, includeBirdseye, onSelectCamera, windowVisible, visibleCameras, isEditMode, setIsEditMode, fullscreen, toggleFullscreen, }: DraggableGridLayoutProps) { const { t } = useTranslation(["views/live"]); const { data: config } = useSWR("config"); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); // preferred live modes per camera 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]); // grid layout const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence< Layout[] >(`${cameraGroup}-draggable-layout`); const [group] = usePersistedOverlayState("cameraGroup", "default" as string); const groups = useMemo(() => { if (!config) { return []; } return Object.entries(config.camera_groups).sort( (a, b) => a[1].order - b[1].order, ); }, [config]); // editing const [editGroup, setEditGroup] = useState(false); const [showCircles, setShowCircles] = useState(true); useEffect(() => { setIsEditMode(false); setEditGroup(false); }, [cameraGroup, setIsEditMode]); // camera state const [currentCameras, setCurrentCameras] = useState(); const [currentIncludeBirdseye, setCurrentIncludeBirdseye] = useState(); const [currentGridLayout, setCurrentGridLayout] = useState< Layout[] | undefined >(); const handleLayoutChange = useCallback( (currentLayout: Layout[]) => { if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) { return; } // save layout to idb setGridLayout(currentLayout); setShowCircles(true); }, [setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout], ); const generateLayout = useCallback(() => { if (!isGridLayoutLoaded) { return; } const cameraNames = includeBirdseye && birdseyeConfig?.enabled ? ["birdseye", ...cameras.map((camera) => camera?.name || "")] : cameras.map((camera) => camera?.name || ""); const optionsMap: Layout[] = currentGridLayout ? currentGridLayout.filter((layout) => cameraNames?.includes(layout.i)) : []; cameraNames.forEach((cameraName, index) => { const existingLayout = optionsMap.find( (layout) => layout.i === cameraName, ); // Skip if the camera already exists in the layout if (existingLayout) { return; } let aspectRatio; let col; // Handle "birdseye" camera as a special case if (cameraName === "birdseye") { aspectRatio = (birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1); col = 0; // Set birdseye camera in the first column } else { const camera = cameras.find((cam) => cam.name === cameraName); aspectRatio = (camera && camera?.detect.width / camera?.detect.height) || 16 / 9; col = index % 3; // Regular cameras distributed across columns } // Calculate layout options based on aspect ratio const columnsPerPlayer = 4; let height; let width; if (aspectRatio < 1) { // Portrait height = 2 * columnsPerPlayer; width = columnsPerPlayer; } else if (aspectRatio > 2) { // Wide height = 1 * columnsPerPlayer; width = 2 * columnsPerPlayer; } else { // Landscape height = 1 * columnsPerPlayer; width = columnsPerPlayer; } const options = { i: cameraName, x: col * width, y: 0, // don't set y, grid does automatically w: width, h: height, }; optionsMap.push(options); }); return optionsMap; }, [ cameras, isGridLayoutLoaded, currentGridLayout, includeBirdseye, birdseyeConfig, ]); useEffect(() => { if (isGridLayoutLoaded) { if (gridLayout) { // set current grid layout from loaded setCurrentGridLayout(gridLayout); } else { // idb is empty, set it with an initial layout setGridLayout(generateLayout()); } } }, [ isEditMode, gridLayout, currentGridLayout, setGridLayout, isGridLayoutLoaded, generateLayout, ]); useEffect(() => { if ( !isEqual(cameras, currentCameras) || includeBirdseye !== currentIncludeBirdseye ) { setCurrentCameras(cameras); setCurrentIncludeBirdseye(includeBirdseye); // set new grid layout in idb setGridLayout(generateLayout()); } }, [ cameras, includeBirdseye, currentCameras, currentIncludeBirdseye, setCurrentGridLayout, generateLayout, setGridLayout, isGridLayoutLoaded, ]); const [marginValue, setMarginValue] = useState(16); // calculate margin value for browsers that don't have default font size of 16px useLayoutEffect(() => { const calculateRemValue = () => { const htmlElement = document.documentElement; const fontSize = window.getComputedStyle(htmlElement).fontSize; setMarginValue(parseFloat(fontSize)); }; calculateRemValue(); }, []); const gridContainerRef = useRef(null); const [{ width: containerWidth, height: containerHeight }] = useResizeObserver(gridContainerRef); const scrollBarWidth = useMemo(() => { if (containerWidth && containerHeight && containerRef.current) { return ( containerRef.current.offsetWidth - containerRef.current.clientWidth ); } return 0; }, [containerRef, containerHeight, containerWidth]); const availableWidth = useMemo( () => (scrollBarWidth ? containerWidth + scrollBarWidth : containerWidth), [containerWidth, scrollBarWidth], ); const hasScrollbar = useMemo(() => { if (containerHeight && containerRef.current) { return ( containerRef.current.offsetHeight < containerRef.current.scrollHeight ); } }, [containerRef, containerHeight]); const cellHeight = useMemo(() => { const aspectRatio = 16 / 9; // subtract container margin, 1 camera takes up at least 4 rows // account for additional margin on bottom of each row return ( ((availableWidth ?? window.innerWidth) - 2 * marginValue) / 12 / aspectRatio - marginValue + marginValue / 4 ); }, [availableWidth, marginValue]); const handleResize: ItemCallback = ( _: Layout[], oldLayoutItem: Layout, layoutItem: Layout, placeholder: Layout, ) => { const heightDiff = layoutItem.h - oldLayoutItem.h; const widthDiff = layoutItem.w - oldLayoutItem.w; const changeCoef = oldLayoutItem.w / oldLayoutItem.h; let newWidth, newHeight; if (Math.abs(heightDiff) < Math.abs(widthDiff)) { newHeight = Math.round(layoutItem.w / changeCoef); newWidth = Math.round(newHeight * changeCoef); } else { newWidth = Math.round(layoutItem.h * changeCoef); newHeight = Math.round(newWidth / changeCoef); } // Ensure dimensions maintain aspect ratio and fit within the grid if (layoutItem.x + newWidth > 12) { newWidth = 12 - layoutItem.x; newHeight = Math.round(newWidth / changeCoef); } if (changeCoef == 0.5) { // portrait newHeight = Math.ceil(newHeight / 2) * 2; } else if (changeCoef == 2) { // pano/wide newHeight = Math.ceil(newHeight * 2) / 2; } newWidth = Math.round(newHeight * changeCoef); layoutItem.w = newWidth; layoutItem.h = newHeight; placeholder.w = layoutItem.w; placeholder.h = layoutItem.h; }; // audio and stats states const [audioStates, setAudioStates] = useState({}); const [volumeStates, setVolumeStates] = useState({}); const [statsStates, setStatsStates] = useState(() => { const initialStates: StatsState = {}; cameras.forEach((camera) => { initialStates[camera.name] = false; }); return initialStates; }); 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) => { setAudioStates((prev) => ({ ...prev, [cameraName]: !prev[cameraName], })); }; const onSaveMuting = useCallback( (playAudio: boolean) => { if (!cameraGroup || !allGroupsStreamingSettings) { 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 = () => { const updatedStates: AudioState = {}; cameras.forEach((camera) => { updatedStates[camera.name] = false; }); setAudioStates(updatedStates); onSaveMuting(false); }; const unmuteAll = () => { const updatedStates: AudioState = {}; cameras.forEach((camera) => { updatedStates[camera.name] = true; }); setAudioStates(updatedStates); onSaveMuting(true); }; return ( <> {!isGridLayoutLoaded || !currentGridLayout || !isEqual(cameras, currentCameras) || includeBirdseye !== currentIncludeBirdseye ? (
{includeBirdseye && birdseyeConfig?.enabled && ( )} {cameras.map((camera) => { return ( ); })}
) : (
setShowCircles(false)} onResizeStop={handleLayoutChange} isDraggable={isEditMode} isResizable={isEditMode} > {includeBirdseye && birdseyeConfig?.enabled && ( onSelectCamera("birdseye")} > {isEditMode && showCircles && } )} {cameras.map((camera) => { let grow; const aspectRatio = camera.detect.width / camera.detect.height; if (aspectRatio > ASPECT_WIDE_LAYOUT) { grow = `aspect-wide w-full`; } else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) { grow = `aspect-tall h-full`; } 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 autoLive = currentGroupStreamingSettings?.[camera.name]?.streamType !== "no-streaming"; 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]} setVolumeState={(value) => setVolumeStates({ [camera.name]: value, }) } muteAll={muteAll} unmuteAll={unmuteAll} resetPreferredLiveMode={() => resetPreferredLiveMode(camera.name) } config={config} > { !isEditMode && onSelectCamera(camera.name); }} onError={(e) => { setPreferredLiveModes((prevModes) => { const newModes = { ...prevModes }; if (e === "mse-decode") { newModes[camera.name] = "webrtc"; } else { newModes[camera.name] = "jsmpeg"; } return newModes; }); }} onResetLiveMode={() => resetPreferredLiveMode(camera.name)} playAudio={audioStates[camera.name]} volume={volumeStates[camera.name]} /> {isEditMode && showCircles && } ); })} {isDesktop && (
setIsEditMode((prevIsEditMode) => !prevIsEditMode) } > {isEditMode ? ( ) : ( )}
{isEditMode ? t("editLayout.exitEdit") : t("editLayout.label")}
{!isEditMode && ( <> {!fullscreen && (
setEditGroup((prevEditGroup) => !prevEditGroup) } >
{isEditMode ? t("editLayout.exitEdit") : t("editLayout.group.label")}
)}
{fullscreen ? ( ) : ( )}
{fullscreen ? t("button.exitFullscreen", { ns: "common" }) : t("button.fullscreen", { ns: "common" })}
)}
)}
)} ); } function CornerCircles() { return ( <>
); } type BirdseyeLivePlayerGridItemProps = { style?: React.CSSProperties; className?: string; onMouseDown?: React.MouseEventHandler; onMouseUp?: React.MouseEventHandler; onTouchEnd?: React.TouchEventHandler; children?: React.ReactNode; birdseyeConfig: BirdseyeConfig; liveMode: LivePlayerMode; onClick: () => void; }; const BirdseyeLivePlayerGridItem = React.forwardRef< HTMLDivElement, BirdseyeLivePlayerGridItemProps >( ( { style, className, onMouseDown, onMouseUp, onTouchEnd, children, birdseyeConfig, liveMode, onClick, ...props }, ref, ) => { return (
} /> {children}
); }, ); type GridLiveContextMenuProps = { className?: string; style?: React.CSSProperties; onMouseDown?: React.MouseEventHandler; onMouseUp?: React.MouseEventHandler; onTouchEnd?: React.TouchEventHandler; children?: React.ReactNode; camera: string; streamName: string; cameraGroup: string; preferredLiveMode: string; isRestreamed: boolean; supportsAudio: boolean; audioState: boolean; toggleAudio: () => void; statsState: boolean; toggleStats: () => void; volumeState?: number; setVolumeState: (volumeState: number) => void; muteAll: () => void; unmuteAll: () => void; resetPreferredLiveMode: () => void; config?: FrigateConfig; }; const GridLiveContextMenu = React.forwardRef< HTMLDivElement, GridLiveContextMenuProps >( ( { className, style, onMouseDown, onMouseUp, onTouchEnd, children, camera, streamName, cameraGroup, preferredLiveMode, isRestreamed, supportsAudio, audioState, toggleAudio, statsState, toggleStats, volumeState, setVolumeState, muteAll, unmuteAll, resetPreferredLiveMode, config, ...props }, ref, ) => { return (
{children}
); }, );