import { usePersistence } from "@/hooks/use-persistence"; import { BirdseyeConfig, CameraConfig, FrigateConfig, } from "@/types/frigateConfig"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Layout, Responsive, WidthProvider } from "react-grid-layout"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; import { LivePlayerMode } 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 { isSafari } from "react-device-detect"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import LivePlayer from "@/components/player/LivePlayer"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { IoClose } from "react-icons/io5"; import { LuMoveDiagonal2 } from "react-icons/lu"; type DraggableGridLayoutProps = { cameras: CameraConfig[]; cameraGroup: string; cameraRef: (node: HTMLElement | null) => void; includeBirdseye: boolean; onSelectCamera: (camera: string) => void; windowVisible: boolean; visibleCameras: string[]; }; export default function DraggableGridLayout({ cameras, cameraGroup, cameraRef, includeBirdseye, onSelectCamera, windowVisible, visibleCameras, }: DraggableGridLayoutProps) { const { data: config } = useSWR("config"); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence< Layout[] >(`${cameraGroup}-draggable-layout`); const [currentCameras, setCurrentCameras] = useState(); const [currentIncludeBirdseye, setCurrentIncludeBirdseye] = useState(); const [currentGridLayout, setCurrentGridLayout] = useState< Layout[] | undefined >(); const [isEditMode, setIsEditMode] = useState(false); const handleLayoutChange = useCallback( (currentLayout: Layout[]) => { if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) { return; } // save layout to idb setGridLayout(currentLayout); }, [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, isDraggable: isEditMode, isResizable: isEditMode, }; optionsMap.push(options); }); return optionsMap; }, [ cameras, isEditMode, isGridLayoutLoaded, currentGridLayout, includeBirdseye, birdseyeConfig, ]); const toggleEditMode = useCallback(() => { if (currentGridLayout) { const updatedGridLayout = currentGridLayout.map((layout) => ({ ...layout, isDraggable: !isEditMode, isResizable: !isEditMode, })); if (isEditMode) { setGridLayout(updatedGridLayout); setCurrentGridLayout(updatedGridLayout); } else { setGridLayout(updatedGridLayout); } setIsEditMode((prevIsEditMode) => !prevIsEditMode); } }, [currentGridLayout, isEditMode, setGridLayout]); 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 gridContainerRef = useRef(null); const [{ width: containerWidth }] = useResizeObserver(gridContainerRef); const cellHeight = useMemo(() => { const aspectRatio = 16 / 9; const totalMarginWidth = 11 * 13; // 11 margins with 13px each const rowHeight = ((containerWidth ?? window.innerWidth) - totalMarginWidth) / (13 * aspectRatio); return rowHeight; }, [containerWidth]); return ( <> {!isGridLayoutLoaded || !currentGridLayout ? (
{includeBirdseye && birdseyeConfig?.enabled && ( )} {cameras.map((camera) => { return ( ); })}
) : (
{includeBirdseye && birdseyeConfig?.enabled && ( onSelectCamera("birdseye")} > {isEditMode && ( <>
)} )} {cameras.map((camera) => { let grow; const aspectRatio = camera.detect.width / camera.detect.height; if (aspectRatio > ASPECT_WIDE_LAYOUT) { grow = `aspect-wide`; } else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) { grow = `aspect-tall`; } else { grow = "aspect-video"; } return ( { !isEditMode && onSelectCamera(camera.name); }} > {isEditMode && ( <>
)} ); })}
{isEditMode ? "Exit Editing" : "Edit Layout"}
)} ); } 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 LivePlayerGridItemProps = { style?: React.CSSProperties; className: string; onMouseDown?: React.MouseEventHandler; onMouseUp?: React.MouseEventHandler; onTouchEnd?: React.TouchEventHandler; children?: React.ReactNode; cameraRef: (node: HTMLElement | null) => void; windowVisible: boolean; cameraConfig: CameraConfig; preferredLiveMode: LivePlayerMode; onClick: () => void; }; const LivePlayerGridItem = React.forwardRef< HTMLDivElement, LivePlayerGridItemProps >( ( { style, className, onMouseDown, onMouseUp, onTouchEnd, children, cameraRef, windowVisible, cameraConfig, preferredLiveMode, onClick, ...props }, ref, ) => { return (
{children}
); }, );