import { useUserPersistence } from "@/hooks/use-user-persistence"; import { AllGroupsStreamingSettings, BirdseyeConfig, CameraConfig, FrigateConfig, } from "@/types/frigateConfig"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useResizeObserver } from "@/hooks/resize-observer"; import { Layout, LayoutItem, ResponsiveGridLayout as Responsive, } from "react-grid-layout"; import { aspectRatio, getCompactor } from "react-grid-layout/core"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; import { AudioState, LivePlayerError, LivePlayerMode, LiveStreamMetadata, StatsState, VolumeState, } from "@/types/live"; import { ASPECT_WIDE_LAYOUT } from "@/types/record"; import { Skeleton } from "@/components/ui/skeleton"; import { isEqual } from "lodash"; import useSWR from "swr"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { isDesktop, isMobile, isMobileOnly } 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 { useUserPersistedOverlayState } 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 LiveContextMenu from "@/components/menu/LiveContextMenu"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useTranslation } from "react-i18next"; // rowHeight is 1/VERTICAL_RESOLUTION of a column, so h = round(w * // VERTICAL_RESOLUTION / aspect) lands a tile on its camera's aspect. GRID_COLS // also sets resize granularity: the aspect constraint derives height from // width, making one column the smallest step in both axes. const GRID_COLS = 96; const TILE_BASE_W = 32; const TILE_WIDE_W = 64; const VERTICAL_RESOLUTION = 4; const DEFAULT_ASPECT = 16 / 9; // Bucketed tile shapes, matching the aspect-wide / aspect-tall Tailwind utilities. const TILE_ASPECT_WIDE = 32 / 9; const TILE_ASPECT_TALL = 8 / 9; // Cells quantize to whole rows/columns, so the card takes the camera's exact // ratio and fits itself inside its cell. --ar and container-type live on the // cell; min() picks whichever axis binds first. const CARD_FIT = "h-auto w-[min(100%,calc(100cqh*var(--ar)))] aspect-[var(--ar)]"; // Stored coordinates are grid units, so bump this whenever GRID_COLS or // VERTICAL_RESOLUTION changes in a released version. const LAYOUT_VERSION = 2; type PersistedLayout = { version: number; naturalAspect: boolean; layout: Layout; }; // Without preventCollision, RGL shoves collided tiles down the page and never // compacts them back. const FREE_PLACEMENT_COMPACTOR = getCompactor(null, false, true); // 0.17/0.18 stored a bare array on a 12-column grid whose standard tile was // 4x4. Bucketed mode reproduces that geometry, so those layouts convert exactly. const LEGACY_GRID_COLS = 12; const LEGACY_TILE_ROWS = 4; 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; preferredLiveModes: { [key: string]: LivePlayerMode }; handleError: (cameraName: string, error: LivePlayerError) => void; resetPreferredLiveMode: (cameraName: string) => void; isRestreamedStates: { [key: string]: boolean }; supportsAudioOutputStates: { [key: string]: { supportsAudio: boolean; cameraName: string }; }; streamMetadata: { [key: string]: LiveStreamMetadata }; }; export default function DraggableGridLayout({ cameras, cameraGroup, containerRef, cameraRef, includeBirdseye, onSelectCamera, windowVisible, visibleCameras, isEditMode, setIsEditMode, fullscreen, toggleFullscreen, preferredLiveModes, handleError, resetPreferredLiveMode, isRestreamedStates, supportsAudioOutputStates, streamMetadata, }: DraggableGridLayoutProps) { const { t } = useTranslation(["views/live"]); const { data: config } = useSWR("config"); const isAdmin = useIsAdmin(); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const aspectRatios = useMemo(() => { const map: { [key: string]: number } = {}; if (birdseyeConfig) { map["birdseye"] = (birdseyeConfig.width || 1) / (birdseyeConfig.height || 1); } cameras.forEach((camera) => { map[camera.name] = camera.detect.width / camera.detect.height || DEFAULT_ASPECT; }); return map; }, [cameras, birdseyeConfig]); const [naturalAspectSetting, , isNaturalAspectLoaded] = useUserPersistence( "naturalAspectLayout", false, ); // phones never reach this grid, and the setting is hidden there, so an // imported or stale value must not take effect const naturalAspectLayout = !isMobileOnly && (naturalAspectSetting ?? false); // Bucketed mode snaps every camera to one of three tile shapes, matching the // pre-masonry layout; the picture letterboxes inside its bucket. const layoutAspects = useMemo(() => { if (naturalAspectLayout) { return aspectRatios; } const map: { [key: string]: number } = {}; Object.entries(aspectRatios).forEach(([name, ratio]) => { map[name] = ratio > ASPECT_WIDE_LAYOUT ? TILE_ASPECT_WIDE : ratio < 1 ? TILE_ASPECT_TALL : DEFAULT_ASPECT; }); return map; }, [aspectRatios, naturalAspectLayout]); // A live stream can be shaped differently than detect, so the card follows // whatever is on screen and falls back to detect. Cells stay detect-sized, so // only the card resizes. const [liveAspects, setLiveAspects] = useState<{ [key: string]: number | undefined; }>({}); const liveAspectHandlers = useMemo(() => { const map: { [key: string]: (aspectRatio: number | undefined) => void } = {}; cameras.forEach((camera) => { map[camera.name] = (aspectRatio) => setLiveAspects((prev) => prev[camera.name] === aspectRatio ? prev : { ...prev, [camera.name]: aspectRatio }, ); }); return map; }, [cameras]); // preferred live modes per camera const [globalAutoLive] = useUserPersistence("autoLiveView", true); const [displayCameraNames] = useUserPersistence("displayCameraNames", false); const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } = useStreamingSettings(); const currentGroupStreamingSettings = useMemo(() => { if (cameraGroup && cameraGroup != "default" && allGroupsStreamingSettings) { return allGroupsStreamingSettings[cameraGroup]; } }, [allGroupsStreamingSettings, cameraGroup]); // grid layout const [gridLayout, setGridLayout, isGridLayoutLoaded] = useUserPersistence(`${cameraGroup}-draggable-layout`); const readPersistedLayout = useCallback( (stored: PersistedLayout | undefined): Layout | undefined => { if ( !stored || stored.version !== LAYOUT_VERSION || !Array.isArray(stored.layout) ) { return undefined; } return stored.layout; }, [], ); // Strips per-item `constraints`, which are functions. const toPersisted = useCallback( (layout: Layout): PersistedLayout => ({ version: LAYOUT_VERSION, naturalAspect: naturalAspectLayout, layout: layout.map(({ i, x, y, w, h }) => ({ i, x, y, w, h })), }), [naturalAspectLayout], ); const [group] = useUserPersistedOverlayState( "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); // Keeps the camera-change effect from overwriting the layout we load next. setCurrentCameras(undefined); setCurrentIncludeBirdseye(undefined); setCurrentGridLayout(undefined); setCurrentNaturalAspect(undefined); }, [cameraGroup, setIsEditMode]); // camera state const [currentCameras, setCurrentCameras] = useState(); const [currentIncludeBirdseye, setCurrentIncludeBirdseye] = useState(); const [currentGridLayout, setCurrentGridLayout] = useState< Layout | undefined >(); const [currentNaturalAspect, setCurrentNaturalAspect] = useState(); const handleLayoutChange = useCallback( (currentLayout: Layout) => { if ( !isGridLayoutLoaded || !isEqual(readPersistedLayout(gridLayout), currentGridLayout) ) { return; } setGridLayout(toPersisted(currentLayout)); setShowCircles(true); }, [ setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout, readPersistedLayout, toPersisted, ], ); const dimsFor = useCallback( (name: string) => { const ratio = layoutAspects[name] ?? DEFAULT_ASPECT; const w = ratio >= ASPECT_WIDE_LAYOUT ? TILE_WIDE_W : TILE_BASE_W; const h = Math.max(1, Math.round((w * VERTICAL_RESOLUTION) / ratio)); return { w, h }; }, [layoutAspects], ); // Rescale a pre-masonry layout onto the current grid. Both axes scale by a // constant, so tiles the user resized keep their size and their arrangement // stays intact. Only meaningful in bucketed mode, where a tile still has the // shape those coordinates assumed. const convertLegacyLayout = useCallback( (stored: unknown): Layout | undefined => { if (naturalAspectLayout || !Array.isArray(stored) || !stored.length) { return undefined; } const xScale = GRID_COLS / LEGACY_GRID_COLS; const yScale = Math.round((TILE_BASE_W * VERTICAL_RESOLUTION) / DEFAULT_ASPECT) / LEGACY_TILE_ROWS; const converted: LayoutItem[] = []; for (const item of stored) { if ( !item || typeof item.i !== "string" || typeof item.x !== "number" || typeof item.y !== "number" || typeof item.w !== "number" || typeof item.h !== "number" ) { return undefined; } const w = Math.min(Math.max(1, Math.round(item.w * xScale)), GRID_COLS); converted.push({ i: item.i, x: Math.min(Math.max(0, Math.round(item.x * xScale)), GRID_COLS - w), y: Math.max(0, Math.round(item.y * yScale)), w, h: Math.max(1, Math.round(item.h * yScale)), }); } return converted; }, [naturalAspectLayout], ); const generateLayout = useCallback( (baseLayout: Layout | undefined): Layout | undefined => { if (!isGridLayoutLoaded) { return; } const cameraNames = includeBirdseye && birdseyeConfig?.enabled ? ["birdseye", ...cameras.map((camera) => camera?.name || "")] : cameras.map((camera) => camera?.name || ""); const existing: LayoutItem[] = baseLayout ? baseLayout.filter((layout) => cameraNames.includes(layout.i)) : []; const placed = new Set(existing.map((layout) => layout.i)); const tileColumns = GRID_COLS / TILE_BASE_W; // 3 standard columns // Each column starts below every existing tile that overlaps it, so new // cameras fill open columns without overlapping the user's tiles. const colBottoms = Array.from({ length: tileColumns }, (_, c) => existing.reduce( (max, layout) => layout.x < (c + 1) * TILE_BASE_W && layout.x + layout.w > c * TILE_BASE_W ? Math.max(max, layout.y + layout.h) : max, 0, ), ); const result: LayoutItem[] = [...existing]; cameraNames.forEach((name) => { if (placed.has(name)) { return; } const { w, h } = dimsFor(name); if (w === TILE_BASE_W) { let col = 0; for (let c = 1; c < tileColumns; c++) { if (colBottoms[c] < colBottoms[col]) { col = c; } } result.push({ i: name, x: col * TILE_BASE_W, y: colBottoms[col], w, h, }); colBottoms[col] += h; } else { let pair = 0; for (let c = 1; c + 1 < tileColumns; c++) { if ( Math.max(colBottoms[c], colBottoms[c + 1]) < Math.max(colBottoms[pair], colBottoms[pair + 1]) ) { pair = c; } } const y = Math.max(colBottoms[pair], colBottoms[pair + 1]); result.push({ i: name, x: pair * TILE_BASE_W, y, w, h }); colBottoms[pair] = y + h; colBottoms[pair + 1] = y + h; } }); return result; }, [cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig, dimsFor], ); useEffect(() => { if (!isGridLayoutLoaded) { return; } const saved = readPersistedLayout(gridLayout); const converted = saved ? undefined : convertLegacyLayout(gridLayout); const base = saved ?? converted; if (base) { const updatedLayout = generateLayout(base) ?? base; setCurrentGridLayout(updatedLayout); if (converted || !isEqual(updatedLayout, base)) { setGridLayout(toPersisted(updatedLayout)); } setCurrentCameras(cameras); setCurrentIncludeBirdseye(includeBirdseye); setCurrentNaturalAspect( converted ? naturalAspectLayout : gridLayout?.naturalAspect, ); } else { // empty or incompatible (pre-masonry) data const newLayout = generateLayout(undefined) ?? []; setCurrentGridLayout(newLayout); setGridLayout(toPersisted(newLayout)); setCurrentCameras(cameras); setCurrentIncludeBirdseye(includeBirdseye); setCurrentNaturalAspect(naturalAspectLayout); } }, [ gridLayout, setGridLayout, isGridLayoutLoaded, generateLayout, cameras, includeBirdseye, naturalAspectLayout, readPersistedLayout, convertLegacyLayout, toPersisted, ]); useEffect(() => { // Only for camera changes within a loaded group; undefined currentCameras // means the load effect above has not run yet. if (!isGridLayoutLoaded || currentCameras === undefined) { return; } if ( !isEqual(cameras, currentCameras) || includeBirdseye !== currentIncludeBirdseye ) { setCurrentCameras(cameras); setCurrentIncludeBirdseye(includeBirdseye); const updatedLayout = generateLayout(currentGridLayout) ?? currentGridLayout ?? []; setCurrentGridLayout(updatedLayout); setGridLayout(toPersisted(updatedLayout)); } }, [ cameras, includeBirdseye, currentCameras, currentIncludeBirdseye, currentGridLayout, generateLayout, setGridLayout, isGridLayoutLoaded, toPersisted, ]); useEffect(() => { if ( !isNaturalAspectLoaded || currentNaturalAspect === undefined || currentNaturalAspect === naturalAspectLayout ) { return; } setCurrentNaturalAspect(naturalAspectLayout); const regenerated = generateLayout(undefined) ?? []; setCurrentGridLayout(regenerated); setGridLayout(toPersisted(regenerated)); }, [ naturalAspectLayout, isNaturalAspectLoaded, currentNaturalAspect, generateLayout, setGridLayout, toPersisted, ]); const gridContainerRef = useRef(null); // Commit-time measure: paints the first frame at the real width (no // innerWidth flash), and the setState re-render is what lets // useResizeObserver see a node mounted after the skeleton swap. const [mountWidth, setMountWidth] = useState(null); const attachGridContainer = useCallback((node: HTMLDivElement | null) => { gridContainerRef.current = node; setMountWidth(node ? node.getBoundingClientRect().width : null); }, []); const [{ width: containerWidth, height: containerHeight }] = useResizeObserver(gridContainerRef); const availableWidth = containerWidth || mountWidth || 0; const hasScrollbar = useMemo(() => { if (containerHeight && containerRef.current) { return ( containerRef.current.offsetHeight < containerRef.current.scrollHeight ); } }, [containerRef, containerHeight]); const cellHeight = useMemo(() => { const width = availableWidth || window.innerWidth; const columnWidth = width / GRID_COLS; return columnWidth / VERTICAL_RESOLUTION; }, [availableWidth]); // 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(() => { const initialAudioStates: AudioState = {}; const initialVolumeStates: VolumeState = {}; Object.entries(currentGroupStreamingSettings ?? {}).forEach( ([camera, cameraSettings]) => { initialAudioStates[camera] = cameraSettings.playAudio ?? false; initialVolumeStates[camera] = cameraSettings.volume ?? 1; }, ); setAudioStates(initialAudioStates); setVolumeStates(initialVolumeStates); }, [currentGroupStreamingSettings, cameraGroup]); 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); }; // RGL's per-item constraint derives height from width, holding each tile at // its camera's aspect while resizing. Constraints are functions, so they live // only on this render copy; toPersisted strips them. const layoutWithConstraints = useMemo(() => { if (!currentGridLayout) { return [] as Layout; } return currentGridLayout.map((item) => ({ ...item, constraints: [aspectRatio(layoutAspects[item.i] ?? DEFAULT_ASPECT)], })); }, [currentGridLayout, layoutAspects]); return ( <> {!isGridLayoutLoaded || !currentGridLayout || !isEqual(cameras, currentCameras) || includeBirdseye !== currentIncludeBirdseye ? (
{includeBirdseye && birdseyeConfig?.enabled && ( )} {cameras.map((camera) => { return ( ); })}
) : (
setShowCircles(false)} onResizeStop={handleLayoutChange} > {includeBirdseye && birdseyeConfig?.enabled && ( onSelectCamera("birdseye")} > )} {cameras.map((camera) => { 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((prev) => ({ ...prev, [camera.name]: value, })) } muteAll={muteAll} unmuteAll={unmuteAll} resetPreferredLiveMode={() => resetPreferredLiveMode(camera.name) } config={config} streamMetadata={streamMetadata} > { if (!isEditMode) { onSelectCamera(camera.name); } }} onError={(e) => handleError(camera.name, e)} onResetLiveMode={() => resetPreferredLiveMode(camera.name)} playAudio={audioStates[camera.name]} volume={volumeStates[camera.name] ?? 1} onLiveAspectChange={liveAspectHandlers[camera.name]} /> ); })} {isDesktop && (
setIsEditMode((prevIsEditMode) => !prevIsEditMode) } > {isEditMode ? ( ) : ( )}
{isEditMode ? t("editLayout.exitEdit") : t("editLayout.label")}
{!isEditMode && ( <> {!fullscreen && isAdmin && (
setEditGroup((prevEditGroup) => !prevEditGroup) } >
{isEditMode ? t("editLayout.exitEdit") : t("editLayout.group.label")}
)}
{fullscreen ? ( ) : ( )}
{fullscreen ? t("button.exitFullscreen", { ns: "common" }) : t("button.fullscreen", { ns: "common" })}
)}
)}
)} ); } type BirdseyeLivePlayerGridItemProps = { style?: React.CSSProperties; className?: string; onMouseDown?: React.MouseEventHandler; onMouseUp?: React.MouseEventHandler; onTouchEnd?: React.TouchEventHandler; children?: React.ReactNode; birdseyeConfig: BirdseyeConfig; aspectRatio: number; liveMode: LivePlayerMode; onClick: () => void; }; const BirdseyeLivePlayerGridItem = React.forwardRef< HTMLDivElement, BirdseyeLivePlayerGridItemProps >( ( { style, className, onMouseDown, onMouseUp, onTouchEnd, children, birdseyeConfig, aspectRatio: cellAspect, liveMode, onClick, ...props }, ref, ) => { return (
} /> {children}
); }, ); type GridLiveContextMenuProps = { className?: string; style?: React.CSSProperties; aspectRatio?: number; 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; streamMetadata?: { [key: string]: LiveStreamMetadata }; }; const GridLiveContextMenu = React.forwardRef< HTMLDivElement, GridLiveContextMenuProps >( ( { className, style, aspectRatio: cameraAspect, onMouseDown, onMouseUp, onTouchEnd, children, camera, streamName, cameraGroup, preferredLiveMode, isRestreamed, supportsAudio, audioState, toggleAudio, statsState, toggleStats, volumeState, setVolumeState, muteAll, unmuteAll, resetPreferredLiveMode, config, streamMetadata, ...props }, ref, ) => { return (
{children}
); }, );