diff --git a/web/package-lock.json b/web/package-lock.json index 122c26570..1df2344a4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -48,6 +48,7 @@ "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-grid-layout": "^1.4.4", "react-hook-form": "^7.51.3", "react-icons": "^5.1.0", "react-konva": "^18.2.10", @@ -76,6 +77,7 @@ "@types/node": "^20.12.7", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", + "@types/react-grid-layout": "^1.3.5", "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", "@types/strftime": "^0.9.8", @@ -2572,6 +2574,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz", + "integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-icons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz", @@ -4392,6 +4403,11 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -6329,6 +6345,44 @@ "react": "^18.2.0" } }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-grid-layout": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.4.4.tgz", + "integrity": "sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==", + "dependencies": { + "clsx": "^2.0.0", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.5", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-hook-form": { "version": "7.51.3", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.51.3.tgz", @@ -6448,6 +6502,18 @@ } } }, + "node_modules/react-resizable": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", + "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + }, + "peerDependencies": { + "react": ">= 16.3" + } + }, "node_modules/react-router": { "version": "6.22.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", @@ -6639,6 +6705,11 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", diff --git a/web/package.json b/web/package.json index 3e8fc2907..980223748 100644 --- a/web/package.json +++ b/web/package.json @@ -53,6 +53,7 @@ "react-day-picker": "^8.10.1", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", + "react-grid-layout": "^1.4.4", "react-hook-form": "^7.51.3", "react-icons": "^5.1.0", "react-konva": "^18.2.10", @@ -81,6 +82,7 @@ "@types/node": "^20.12.7", "@types/react": "^18.2.79", "@types/react-dom": "^18.2.25", + "@types/react-grid-layout": "^1.3.5", "@types/react-icons": "^3.0.0", "@types/react-transition-group": "^4.4.10", "@types/strftime": "^0.9.8", diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 128645ce2..84d1a36b5 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -59,6 +59,7 @@ import { Toaster } from "@/components/ui/sonner"; import { toast } from "sonner"; import ActivityIndicator from "../indicators/activity-indicator"; import { ScrollArea, ScrollBar } from "../ui/scroll-area"; +import { usePersistence } from "@/hooks/use-persistence"; import { TooltipPortal } from "@radix-ui/react-tooltip"; import { cn } from "@/lib/utils"; @@ -89,7 +90,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { // groups - const [group, setGroup] = usePersistedOverlayState( + const [group, setGroup, deleteGroup] = usePersistedOverlayState( "cameraGroup", "default" as string, ); @@ -118,6 +119,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { currentGroups={groups} activeGroup={group} setGroup={setGroup} + deleteGroup={deleteGroup} />
void; + deleteGroup: () => void; }; function NewGroupDialog({ open, @@ -205,6 +208,7 @@ function NewGroupDialog({ currentGroups, activeGroup, setGroup, + deleteGroup, }: NewGroupDialogProps) { const { mutate: updateConfig } = useSWR("config"); @@ -225,11 +229,16 @@ function NewGroupDialog({ const [editState, setEditState] = useState<"none" | "add" | "edit">("none"); const [isLoading, setIsLoading] = useState(false); + const [, , , deleteGridLayout] = usePersistence( + `${activeGroup}-draggable-layout`, + ); + // callbacks const onDeleteGroup = useCallback( async (name: string) => { - // TODO: reset order on groups when deleting + deleteGridLayout(); + deleteGroup(); await axios .put(`config/set?camera_groups.${name}`, { requires_restart: 0 }) @@ -260,7 +269,14 @@ function NewGroupDialog({ setIsLoading(false); }); }, - [updateConfig, activeGroup, setGroup, setOpen], + [ + updateConfig, + activeGroup, + setGroup, + setOpen, + deleteGroup, + deleteGridLayout, + ], ); const onSave = () => { @@ -479,7 +495,11 @@ export function CameraGroupEdit({ { message: "Camera group name already exists.", }, - ), + ) + .refine((value: string) => value.toLowerCase() !== "default", { + message: "Invalid camera group name.", + }), + cameras: z.array(z.string()).min(2, { message: "You must select at least two cameras.", }), diff --git a/web/src/components/settings/General.tsx b/web/src/components/settings/General.tsx index 70f6203b2..bdd30fdb7 100644 --- a/web/src/components/settings/General.tsx +++ b/web/src/components/settings/General.tsx @@ -1,19 +1,91 @@ import Heading from "@/components/ui/heading"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { useEffect } from "react"; +import { useCallback, useEffect } from "react"; +import { Toaster } from "sonner"; +import { toast } from "sonner"; +import { Separator } from "../ui/separator"; +import { Button } from "../ui/button"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { del as delData } from "idb-keyval"; export default function General() { + const { data: config } = useSWR("config"); + + const clearStoredLayouts = useCallback(() => { + if (!config) { + return []; + } + + Object.entries(config.camera_groups).forEach(async (value) => { + await delData(`${value[0]}-draggable-layout`) + .then(() => { + toast.success(`Cleared stored layout for ${value[0]}`, { + position: "top-center", + }); + }) + .catch((error) => { + toast.error( + `Failed to clear stored layout: ${error.response.data.message}`, + { position: "top-center" }, + ); + }); + }); + }, [config]); + useEffect(() => { document.title = "General Settings - Frigate"; }, []); return ( <> - Settings -
- {}} /> - +
+ +
+ + General Settings + + +
+
+
+
Stored Layouts
+
+

+ The layout of cameras in a camera group can be + dragged/resized. The positions are stored in your browser's + local storage. +

+
+
+
+ +
+
+ +
+
+
Low Data Mode
+
+

+ Not yet implemented. Default: disabled +

+
+
+
+ {}} + /> + +
+
+
+
); diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index 391712c6a..f39717288 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -33,14 +33,15 @@ export function useOverlayState( export function usePersistedOverlayState( key: string, defaultValue: S | undefined = undefined, -): [S | undefined, (value: S | undefined, replace?: boolean) => void] { - const [persistedValue, setPersistedValue] = usePersistence( - key, - defaultValue, - ); +): [ + S | undefined, + (value: S | undefined, replace?: boolean) => void, + () => void, +] { + const [persistedValue, setPersistedValue, , deletePersistedValue] = + usePersistence(key, defaultValue); const location = useLocation(); const navigate = useNavigate(); - const currentLocationState = useMemo(() => location.state, [location]); const setOverlayStateValue = useCallback( @@ -63,6 +64,7 @@ export function usePersistedOverlayState( return [ overlayStateValue ?? persistedValue ?? defaultValue, setOverlayStateValue, + deletePersistedValue, ]; } diff --git a/web/src/hooks/use-persistence.ts b/web/src/hooks/use-persistence.ts index 1b2f2a4d4..8762c1970 100644 --- a/web/src/hooks/use-persistence.ts +++ b/web/src/hooks/use-persistence.ts @@ -1,10 +1,11 @@ import { useEffect, useState, useCallback } from "react"; -import { get as getData, set as setData } from "idb-keyval"; +import { get as getData, set as setData, del as delData } from "idb-keyval"; type usePersistenceReturn = [ value: S | undefined, setValue: (value: S | undefined) => void, loaded: boolean, + deleteValue: () => void, ]; export function usePersistence( @@ -26,6 +27,11 @@ export function usePersistence( [key], ); + const deleteValue = useCallback(async () => { + await delData(key); + setInternalValue(defaultValue); + }, [key, defaultValue]); + useEffect(() => { setLoaded(false); setInternalValue(defaultValue); @@ -41,5 +47,5 @@ export function usePersistence( load(); }, [key, defaultValue, setValue]); - return [value, setValue, loaded]; + return [value, setValue, loaded, deleteValue]; } diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index 387aa3856..bae56b6bb 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -89,6 +89,7 @@ function Live() { return ( diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx new file mode 100644 index 000000000..f0a64b92a --- /dev/null +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -0,0 +1,461 @@ +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} +
+ ); + }, +); diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 31be2dce1..1c7d2d3e0 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -12,16 +12,24 @@ import { usePersistence } from "@/hooks/use-persistence"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { ReviewSegment } from "@/types/review"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { isDesktop, isMobile, isSafari } from "react-device-detect"; +import { + isDesktop, + isMobile, + isMobileOnly, + isSafari, +} from "react-device-detect"; import useSWR from "swr"; +import DraggableGridLayout from "./DraggableGridLayout"; type LiveDashboardViewProps = { cameras: CameraConfig[]; + cameraGroup?: string; includeBirdseye: boolean; onSelectCamera: (camera: string) => void; }; export default function LiveDashboardView({ cameras, + cameraGroup, includeBirdseye, onSelectCamera, }: LiveDashboardViewProps) { @@ -29,7 +37,7 @@ export default function LiveDashboardView({ // layout - const [layout, setLayout] = usePersistence<"grid" | "list">( + const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">( "live-layout", isDesktop ? "grid" : "list", ); @@ -150,25 +158,25 @@ export default function LiveDashboardView({
@@ -187,41 +195,53 @@ export default function LiveDashboardView({ )} -
- {includeBirdseye && birdseyeConfig?.enabled && ( - onSelectCamera("birdseye")} - /> - )} - {cameras.map((camera) => { - let grow; - const aspectRatio = camera.detect.width / camera.detect.height; - if (aspectRatio > 2) { - grow = `${layout == "grid" ? "col-span-2" : ""} aspect-wide`; - } else if (aspectRatio < 1) { - grow = `${layout == "grid" ? "row-span-2 aspect-tall md:h-full" : ""} aspect-tall`; - } else { - grow = "aspect-video"; - } - return ( - onSelectCamera(camera.name)} + {!cameraGroup || cameraGroup == "default" || isMobileOnly ? ( +
+ {includeBirdseye && birdseyeConfig?.enabled && ( + onSelectCamera("birdseye")} /> - ); - })} -
+ )} + {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 aspect-tall md:h-full" : ""} aspect-tall`; + } else { + grow = "aspect-video"; + } + return ( + onSelectCamera(camera.name)} + /> + ); + })} +
+ ) : ( + + )}
); }