2025-12-01 07:59:54 -06:00
|
|
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
2024-05-07 09:28:10 -05:00
|
|
|
import {
|
2025-02-10 10:42:35 -06:00
|
|
|
AllGroupsStreamingSettings,
|
2024-05-07 09:28:10 -05:00
|
|
|
BirdseyeConfig,
|
|
|
|
|
CameraConfig,
|
|
|
|
|
FrigateConfig,
|
|
|
|
|
} from "@/types/frigateConfig";
|
|
|
|
|
import React, {
|
|
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
} from "react";
|
2026-09-27 18:09:31 -05:00
|
|
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
2024-05-10 11:54:37 -05:00
|
|
|
import {
|
|
|
|
|
Layout,
|
2026-03-05 08:42:38 -06:00
|
|
|
LayoutItem,
|
|
|
|
|
ResponsiveGridLayout as Responsive,
|
2024-05-10 11:54:37 -05:00
|
|
|
} from "react-grid-layout";
|
2026-09-27 18:09:31 -05:00
|
|
|
import { aspectRatio, getCompactor } from "react-grid-layout/core";
|
2024-05-07 09:28:10 -05:00
|
|
|
import "react-grid-layout/css/styles.css";
|
|
|
|
|
import "react-resizable/css/styles.css";
|
2025-02-10 10:42:35 -06:00
|
|
|
import {
|
|
|
|
|
AudioState,
|
2026-09-16 09:12:06 -05:00
|
|
|
LivePlayerError,
|
2025-02-10 10:42:35 -06:00
|
|
|
LivePlayerMode,
|
2025-11-30 06:54:42 -06:00
|
|
|
LiveStreamMetadata,
|
2025-02-10 10:42:35 -06:00
|
|
|
StatsState,
|
|
|
|
|
VolumeState,
|
|
|
|
|
} from "@/types/live";
|
2026-09-27 18:09:31 -05:00
|
|
|
import { ASPECT_WIDE_LAYOUT } from "@/types/record";
|
2024-05-07 09:28:10 -05:00
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
|
import { isEqual } from "lodash";
|
|
|
|
|
import useSWR from "swr";
|
2026-10-10 12:24:00 -05:00
|
|
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
2026-09-27 18:09:31 -05:00
|
|
|
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
|
2024-05-07 09:28:10 -05:00
|
|
|
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
|
|
|
|
import LivePlayer from "@/components/player/LivePlayer";
|
2024-05-09 16:08:22 -05:00
|
|
|
import { IoClose } from "react-icons/io5";
|
|
|
|
|
import { LuLayoutDashboard, LuPencil } from "react-icons/lu";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { EditGroupDialog } from "@/components/filter/CameraGroupSelector";
|
2025-12-01 07:59:54 -06:00
|
|
|
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
2024-05-09 16:08:22 -05:00
|
|
|
import { FaCompress, FaExpand } from "react-icons/fa";
|
2024-05-07 09:28:10 -05:00
|
|
|
import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
TooltipTrigger,
|
2024-05-09 16:08:22 -05:00
|
|
|
TooltipContent,
|
2024-05-07 09:28:10 -05:00
|
|
|
} from "@/components/ui/tooltip";
|
2024-05-10 11:54:37 -05:00
|
|
|
import { Toaster } from "@/components/ui/sonner";
|
2025-02-10 10:42:35 -06:00
|
|
|
import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
|
|
|
|
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
2025-03-16 23:36:20 +08:00
|
|
|
import { useTranslation } from "react-i18next";
|
2024-05-07 09:28:10 -05:00
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
// 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;
|
|
|
|
|
|
2024-05-07 09:28:10 -05:00
|
|
|
type DraggableGridLayoutProps = {
|
|
|
|
|
cameras: CameraConfig[];
|
|
|
|
|
cameraGroup: string;
|
|
|
|
|
cameraRef: (node: HTMLElement | null) => void;
|
2026-03-05 08:42:38 -06:00
|
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
2024-05-07 09:28:10 -05:00
|
|
|
includeBirdseye: boolean;
|
|
|
|
|
onSelectCamera: (camera: string) => void;
|
|
|
|
|
windowVisible: boolean;
|
|
|
|
|
visibleCameras: string[];
|
2024-05-08 07:53:22 -05:00
|
|
|
isEditMode: boolean;
|
|
|
|
|
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
|
2024-05-31 07:58:33 -05:00
|
|
|
fullscreen: boolean;
|
|
|
|
|
toggleFullscreen: () => void;
|
2025-11-30 06:54:42 -06:00
|
|
|
preferredLiveModes: { [key: string]: LivePlayerMode };
|
2026-09-16 09:12:06 -05:00
|
|
|
handleError: (cameraName: string, error: LivePlayerError) => void;
|
2025-11-30 06:54:42 -06:00
|
|
|
resetPreferredLiveMode: (cameraName: string) => void;
|
|
|
|
|
isRestreamedStates: { [key: string]: boolean };
|
|
|
|
|
supportsAudioOutputStates: {
|
|
|
|
|
[key: string]: { supportsAudio: boolean; cameraName: string };
|
|
|
|
|
};
|
|
|
|
|
streamMetadata: { [key: string]: LiveStreamMetadata };
|
2024-05-07 09:28:10 -05:00
|
|
|
};
|
|
|
|
|
export default function DraggableGridLayout({
|
|
|
|
|
cameras,
|
|
|
|
|
cameraGroup,
|
2024-05-08 07:53:22 -05:00
|
|
|
containerRef,
|
2024-05-07 09:28:10 -05:00
|
|
|
cameraRef,
|
|
|
|
|
includeBirdseye,
|
|
|
|
|
onSelectCamera,
|
|
|
|
|
windowVisible,
|
|
|
|
|
visibleCameras,
|
2024-05-08 07:53:22 -05:00
|
|
|
isEditMode,
|
|
|
|
|
setIsEditMode,
|
2024-05-31 07:58:33 -05:00
|
|
|
fullscreen,
|
|
|
|
|
toggleFullscreen,
|
2025-11-30 06:54:42 -06:00
|
|
|
preferredLiveModes,
|
2026-09-16 09:12:06 -05:00
|
|
|
handleError,
|
2025-11-30 06:54:42 -06:00
|
|
|
resetPreferredLiveMode,
|
|
|
|
|
isRestreamedStates,
|
|
|
|
|
supportsAudioOutputStates,
|
|
|
|
|
streamMetadata,
|
2024-05-07 09:28:10 -05:00
|
|
|
}: DraggableGridLayoutProps) {
|
2025-03-16 23:36:20 +08:00
|
|
|
const { t } = useTranslation(["views/live"]);
|
2024-05-07 09:28:10 -05:00
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
2026-10-10 12:24:00 -05:00
|
|
|
const isAdmin = useIsAdmin();
|
2024-05-07 09:28:10 -05:00
|
|
|
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
|
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
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]);
|
|
|
|
|
|
2024-06-29 10:02:30 -05:00
|
|
|
// preferred live modes per camera
|
|
|
|
|
|
2025-12-01 07:59:54 -06:00
|
|
|
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
|
|
|
|
const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
|
2025-02-10 10:42:35 -06:00
|
|
|
|
|
|
|
|
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
|
|
|
|
useStreamingSettings();
|
|
|
|
|
|
|
|
|
|
const currentGroupStreamingSettings = useMemo(() => {
|
|
|
|
|
if (cameraGroup && cameraGroup != "default" && allGroupsStreamingSettings) {
|
|
|
|
|
return allGroupsStreamingSettings[cameraGroup];
|
|
|
|
|
}
|
|
|
|
|
}, [allGroupsStreamingSettings, cameraGroup]);
|
|
|
|
|
|
|
|
|
|
// grid layout
|
2024-06-29 10:02:30 -05:00
|
|
|
|
2026-03-05 08:42:38 -06:00
|
|
|
const [gridLayout, setGridLayout, isGridLayoutLoaded] =
|
2026-09-27 18:09:31 -05:00
|
|
|
useUserPersistence<PersistedLayout>(`${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],
|
|
|
|
|
);
|
2024-05-07 09:28:10 -05:00
|
|
|
|
2025-12-01 07:59:54 -06:00
|
|
|
const [group] = useUserPersistedOverlayState(
|
|
|
|
|
"cameraGroup",
|
|
|
|
|
"default" as string,
|
|
|
|
|
);
|
2024-05-09 16:08:22 -05:00
|
|
|
|
|
|
|
|
const groups = useMemo(() => {
|
|
|
|
|
if (!config) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Object.entries(config.camera_groups).sort(
|
|
|
|
|
(a, b) => a[1].order - b[1].order,
|
|
|
|
|
);
|
|
|
|
|
}, [config]);
|
|
|
|
|
|
2024-06-04 09:10:19 -06:00
|
|
|
// editing
|
|
|
|
|
|
2024-05-09 16:08:22 -05:00
|
|
|
const [editGroup, setEditGroup] = useState(false);
|
2024-06-06 07:26:02 -05:00
|
|
|
const [showCircles, setShowCircles] = useState(true);
|
2024-05-09 16:08:22 -05:00
|
|
|
|
2024-06-04 09:10:19 -06:00
|
|
|
useEffect(() => {
|
2024-06-13 14:11:48 -05:00
|
|
|
setIsEditMode(false);
|
2024-06-04 09:10:19 -06:00
|
|
|
setEditGroup(false);
|
2026-09-27 18:09:31 -05:00
|
|
|
// Keeps the camera-change effect from overwriting the layout we load next.
|
2025-12-01 07:59:54 -06:00
|
|
|
setCurrentCameras(undefined);
|
|
|
|
|
setCurrentIncludeBirdseye(undefined);
|
|
|
|
|
setCurrentGridLayout(undefined);
|
2026-09-27 18:09:31 -05:00
|
|
|
setCurrentNaturalAspect(undefined);
|
2024-06-13 14:11:48 -05:00
|
|
|
}, [cameraGroup, setIsEditMode]);
|
2024-06-04 09:10:19 -06:00
|
|
|
|
|
|
|
|
// camera state
|
|
|
|
|
|
2024-05-07 09:28:10 -05:00
|
|
|
const [currentCameras, setCurrentCameras] = useState<CameraConfig[]>();
|
|
|
|
|
const [currentIncludeBirdseye, setCurrentIncludeBirdseye] =
|
|
|
|
|
useState<boolean>();
|
|
|
|
|
const [currentGridLayout, setCurrentGridLayout] = useState<
|
2026-03-05 08:42:38 -06:00
|
|
|
Layout | undefined
|
2024-05-07 09:28:10 -05:00
|
|
|
>();
|
2026-09-27 18:09:31 -05:00
|
|
|
const [currentNaturalAspect, setCurrentNaturalAspect] = useState<boolean>();
|
2024-05-07 09:28:10 -05:00
|
|
|
|
|
|
|
|
const handleLayoutChange = useCallback(
|
2026-03-05 08:42:38 -06:00
|
|
|
(currentLayout: Layout) => {
|
2026-09-27 18:09:31 -05:00
|
|
|
if (
|
|
|
|
|
!isGridLayoutLoaded ||
|
|
|
|
|
!isEqual(readPersistedLayout(gridLayout), currentGridLayout)
|
|
|
|
|
) {
|
2024-05-07 09:28:10 -05:00
|
|
|
return;
|
|
|
|
|
}
|
2026-09-27 18:09:31 -05:00
|
|
|
setGridLayout(toPersisted(currentLayout));
|
2024-06-06 07:26:02 -05:00
|
|
|
setShowCircles(true);
|
2024-05-07 09:28:10 -05:00
|
|
|
},
|
2026-09-27 18:09:31 -05:00
|
|
|
[
|
|
|
|
|
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],
|
2024-05-07 09:28:10 -05:00
|
|
|
);
|
|
|
|
|
|
2025-12-01 07:59:54 -06:00
|
|
|
const generateLayout = useCallback(
|
2026-09-27 18:09:31 -05:00
|
|
|
(baseLayout: Layout | undefined): Layout | undefined => {
|
2025-12-01 07:59:54 -06:00
|
|
|
if (!isGridLayoutLoaded) {
|
2024-05-07 09:28:10 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-01 07:59:54 -06:00
|
|
|
const cameraNames =
|
|
|
|
|
includeBirdseye && birdseyeConfig?.enabled
|
|
|
|
|
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
|
|
|
|
: cameras.map((camera) => camera?.name || "");
|
2024-05-07 09:28:10 -05:00
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
const existing: LayoutItem[] = baseLayout
|
|
|
|
|
? baseLayout.filter((layout) => cameraNames.includes(layout.i))
|
2025-12-01 07:59:54 -06:00
|
|
|
: [];
|
2026-09-27 18:09:31 -05:00
|
|
|
const placed = new Set(existing.map((layout) => layout.i));
|
2024-05-07 09:28:10 -05:00
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
);
|
2024-05-07 09:28:10 -05:00
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
const result: LayoutItem[] = [...existing];
|
|
|
|
|
|
|
|
|
|
cameraNames.forEach((name) => {
|
|
|
|
|
if (placed.has(name)) {
|
2025-12-01 07:59:54 -06:00
|
|
|
return;
|
|
|
|
|
}
|
2026-09-27 18:09:31 -05:00
|
|
|
const { w, h } = dimsFor(name);
|
2024-05-07 09:28:10 -05:00
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
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;
|
2025-12-01 07:59:54 -06:00
|
|
|
} else {
|
2026-09-27 18:09:31 -05:00
|
|
|
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;
|
2025-12-01 07:59:54 -06:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
return result;
|
2025-12-01 07:59:54 -06:00
|
|
|
},
|
2026-09-27 18:09:31 -05:00
|
|
|
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig, dimsFor],
|
2025-12-01 07:59:54 -06:00
|
|
|
);
|
2024-05-07 09:28:10 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-09-27 18:09:31 -05:00
|
|
|
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));
|
2024-05-07 09:28:10 -05:00
|
|
|
}
|
2026-09-27 18:09:31 -05:00
|
|
|
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);
|
2024-05-07 09:28:10 -05:00
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
gridLayout,
|
|
|
|
|
setGridLayout,
|
|
|
|
|
isGridLayoutLoaded,
|
|
|
|
|
generateLayout,
|
2025-12-01 07:59:54 -06:00
|
|
|
cameras,
|
|
|
|
|
includeBirdseye,
|
2026-09-27 18:09:31 -05:00
|
|
|
naturalAspectLayout,
|
|
|
|
|
readPersistedLayout,
|
|
|
|
|
convertLegacyLayout,
|
|
|
|
|
toPersisted,
|
2024-05-07 09:28:10 -05:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-09-27 18:09:31 -05:00
|
|
|
// Only for camera changes within a loaded group; undefined currentCameras
|
|
|
|
|
// means the load effect above has not run yet.
|
2025-12-01 07:59:54 -06:00
|
|
|
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-07 09:28:10 -05:00
|
|
|
if (
|
|
|
|
|
!isEqual(cameras, currentCameras) ||
|
|
|
|
|
includeBirdseye !== currentIncludeBirdseye
|
|
|
|
|
) {
|
|
|
|
|
setCurrentCameras(cameras);
|
|
|
|
|
setCurrentIncludeBirdseye(includeBirdseye);
|
|
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
const updatedLayout =
|
|
|
|
|
generateLayout(currentGridLayout) ?? currentGridLayout ?? [];
|
2025-12-01 07:59:54 -06:00
|
|
|
setCurrentGridLayout(updatedLayout);
|
2026-09-27 18:09:31 -05:00
|
|
|
setGridLayout(toPersisted(updatedLayout));
|
2024-05-07 09:28:10 -05:00
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
cameras,
|
|
|
|
|
includeBirdseye,
|
|
|
|
|
currentCameras,
|
|
|
|
|
currentIncludeBirdseye,
|
2025-12-01 07:59:54 -06:00
|
|
|
currentGridLayout,
|
2024-05-07 09:28:10 -05:00
|
|
|
generateLayout,
|
|
|
|
|
setGridLayout,
|
|
|
|
|
isGridLayoutLoaded,
|
2026-09-27 18:09:31 -05:00
|
|
|
toPersisted,
|
2024-05-07 09:28:10 -05:00
|
|
|
]);
|
|
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (
|
|
|
|
|
!isNaturalAspectLoaded ||
|
|
|
|
|
currentNaturalAspect === undefined ||
|
|
|
|
|
currentNaturalAspect === naturalAspectLayout
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2024-05-08 07:53:22 -05:00
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
setCurrentNaturalAspect(naturalAspectLayout);
|
|
|
|
|
const regenerated = generateLayout(undefined) ?? [];
|
|
|
|
|
setCurrentGridLayout(regenerated);
|
|
|
|
|
setGridLayout(toPersisted(regenerated));
|
|
|
|
|
}, [
|
|
|
|
|
naturalAspectLayout,
|
|
|
|
|
isNaturalAspectLoaded,
|
|
|
|
|
currentNaturalAspect,
|
|
|
|
|
generateLayout,
|
|
|
|
|
setGridLayout,
|
|
|
|
|
toPersisted,
|
|
|
|
|
]);
|
2024-05-08 07:53:22 -05:00
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
const gridContainerRef = useRef<HTMLDivElement | null>(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<number | null>(null);
|
|
|
|
|
|
|
|
|
|
const attachGridContainer = useCallback((node: HTMLDivElement | null) => {
|
|
|
|
|
gridContainerRef.current = node;
|
|
|
|
|
setMountWidth(node ? node.getBoundingClientRect().width : null);
|
2024-05-08 07:53:22 -05:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const [{ width: containerWidth, height: containerHeight }] =
|
|
|
|
|
useResizeObserver(gridContainerRef);
|
|
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
const availableWidth = containerWidth || mountWidth || 0;
|
2024-05-29 13:05:28 -05:00
|
|
|
|
2024-05-08 07:53:22 -05:00
|
|
|
const hasScrollbar = useMemo(() => {
|
2024-05-29 13:05:28 -05:00
|
|
|
if (containerHeight && containerRef.current) {
|
|
|
|
|
return (
|
|
|
|
|
containerRef.current.offsetHeight < containerRef.current.scrollHeight
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}, [containerRef, containerHeight]);
|
2024-05-07 09:28:10 -05:00
|
|
|
|
|
|
|
|
const cellHeight = useMemo(() => {
|
2026-09-27 18:09:31 -05:00
|
|
|
const width = availableWidth || window.innerWidth;
|
|
|
|
|
const columnWidth = width / GRID_COLS;
|
|
|
|
|
return columnWidth / VERTICAL_RESOLUTION;
|
|
|
|
|
}, [availableWidth]);
|
2024-05-10 11:54:37 -05:00
|
|
|
|
2025-02-10 10:42:35 -06:00
|
|
|
// audio and stats states
|
|
|
|
|
|
|
|
|
|
const [audioStates, setAudioStates] = useState<AudioState>({});
|
|
|
|
|
const [volumeStates, setVolumeStates] = useState<VolumeState>({});
|
|
|
|
|
const [statsStates, setStatsStates] = useState<StatsState>(() => {
|
|
|
|
|
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 = {};
|
|
|
|
|
|
2026-10-31 22:45:47 -05:00
|
|
|
Object.entries(currentGroupStreamingSettings ?? {}).forEach(
|
|
|
|
|
([camera, cameraSettings]) => {
|
|
|
|
|
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
|
|
|
|
|
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
|
|
|
|
|
},
|
|
|
|
|
);
|
2025-02-10 10:42:35 -06:00
|
|
|
|
|
|
|
|
setAudioStates(initialAudioStates);
|
|
|
|
|
setVolumeStates(initialVolumeStates);
|
2026-10-31 22:45:47 -05:00
|
|
|
}, [currentGroupStreamingSettings, cameraGroup]);
|
2025-02-10 10:42:35 -06:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-27 18:09:31 -05:00
|
|
|
// 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]);
|
|
|
|
|
|
2024-05-07 09:28:10 -05:00
|
|
|
return (
|
|
|
|
|
<>
|
2024-05-10 11:54:37 -05:00
|
|
|
<Toaster position="top-center" closeButton={true} />
|
2024-05-30 15:17:00 -05:00
|
|
|
{!isGridLayoutLoaded ||
|
|
|
|
|
!currentGridLayout ||
|
|
|
|
|
!isEqual(cameras, currentCameras) ||
|
|
|
|
|
includeBirdseye !== currentIncludeBirdseye ? (
|
2024-05-14 10:06:44 -05:00
|
|
|
<div className="mt-2 grid grid-cols-2 gap-2 px-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4">
|
2024-05-07 09:28:10 -05:00
|
|
|
{includeBirdseye && birdseyeConfig?.enabled && (
|
2024-05-08 07:53:22 -05:00
|
|
|
<Skeleton className="size-full rounded-lg md:rounded-2xl" />
|
2024-05-07 09:28:10 -05:00
|
|
|
)}
|
|
|
|
|
{cameras.map((camera) => {
|
|
|
|
|
return (
|
|
|
|
|
<Skeleton
|
|
|
|
|
key={camera.name}
|
2024-05-08 07:53:22 -05:00
|
|
|
className="aspect-video size-full rounded-lg md:rounded-2xl"
|
2024-05-07 09:28:10 -05:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div
|
2026-09-27 18:09:31 -05:00
|
|
|
className="no-scrollbar my-2 select-none overflow-x-hidden pb-8"
|
|
|
|
|
ref={attachGridContainer}
|
2024-05-07 09:28:10 -05:00
|
|
|
>
|
2024-05-09 16:08:22 -05:00
|
|
|
<EditGroupDialog
|
|
|
|
|
open={editGroup}
|
|
|
|
|
setOpen={setEditGroup}
|
|
|
|
|
currentGroups={groups}
|
|
|
|
|
activeGroup={group}
|
|
|
|
|
/>
|
2026-03-05 08:42:38 -06:00
|
|
|
<Responsive
|
2024-05-07 09:28:10 -05:00
|
|
|
className="grid-layout"
|
2026-09-27 18:09:31 -05:00
|
|
|
width={availableWidth || window.innerWidth}
|
2024-05-07 09:28:10 -05:00
|
|
|
layouts={{
|
2026-09-27 18:09:31 -05:00
|
|
|
lg: layoutWithConstraints,
|
|
|
|
|
md: layoutWithConstraints,
|
|
|
|
|
sm: layoutWithConstraints,
|
|
|
|
|
xs: layoutWithConstraints,
|
|
|
|
|
xxs: layoutWithConstraints,
|
2024-05-07 09:28:10 -05:00
|
|
|
}}
|
|
|
|
|
rowHeight={cellHeight}
|
|
|
|
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
2026-09-27 18:09:31 -05:00
|
|
|
cols={{
|
|
|
|
|
lg: GRID_COLS,
|
|
|
|
|
md: GRID_COLS,
|
|
|
|
|
sm: GRID_COLS,
|
|
|
|
|
xs: GRID_COLS,
|
|
|
|
|
xxs: GRID_COLS,
|
|
|
|
|
}}
|
|
|
|
|
margin={[0, 0]}
|
|
|
|
|
compactor={FREE_PLACEMENT_COMPACTOR}
|
2024-05-08 07:53:22 -05:00
|
|
|
containerPadding={[0, isEditMode ? 6 : 3]}
|
2026-03-05 08:42:38 -06:00
|
|
|
resizeConfig={{
|
|
|
|
|
enabled: isEditMode,
|
2026-09-27 18:09:31 -05:00
|
|
|
// se only: top/left handles fight the aspect constraint at a grid
|
|
|
|
|
// boundary (RGL re-clamps the opposite edge) and distort the tile.
|
|
|
|
|
handles: isEditMode ? ["se"] : [],
|
2026-03-05 08:42:38 -06:00
|
|
|
}}
|
|
|
|
|
dragConfig={{
|
|
|
|
|
enabled: isEditMode,
|
|
|
|
|
}}
|
2024-05-07 09:28:10 -05:00
|
|
|
onDragStop={handleLayoutChange}
|
2024-06-06 07:26:02 -05:00
|
|
|
onResizeStart={() => setShowCircles(false)}
|
2024-05-07 09:28:10 -05:00
|
|
|
onResizeStop={handleLayoutChange}
|
|
|
|
|
>
|
|
|
|
|
{includeBirdseye && birdseyeConfig?.enabled && (
|
|
|
|
|
<BirdseyeLivePlayerGridItem
|
|
|
|
|
key="birdseye"
|
2024-05-08 07:53:22 -05:00
|
|
|
className={cn(
|
|
|
|
|
isEditMode &&
|
2024-06-06 07:26:02 -05:00
|
|
|
showCircles &&
|
2024-05-14 10:06:44 -05:00
|
|
|
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
2024-05-08 07:53:22 -05:00
|
|
|
)}
|
2024-05-07 09:28:10 -05:00
|
|
|
birdseyeConfig={birdseyeConfig}
|
2026-09-27 18:09:31 -05:00
|
|
|
aspectRatio={layoutAspects["birdseye"] ?? DEFAULT_ASPECT}
|
2024-05-07 09:28:10 -05:00
|
|
|
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
|
|
|
|
onClick={() => onSelectCamera("birdseye")}
|
2026-09-27 18:09:31 -05:00
|
|
|
></BirdseyeLivePlayerGridItem>
|
2024-05-07 09:28:10 -05:00
|
|
|
)}
|
|
|
|
|
{cameras.map((camera) => {
|
2025-03-23 13:51:06 -05:00
|
|
|
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;
|
2025-06-03 07:33:32 -05:00
|
|
|
const streamType =
|
|
|
|
|
currentGroupStreamingSettings?.[camera.name]?.streamType;
|
2025-02-10 10:42:35 -06:00
|
|
|
const autoLive =
|
2025-06-03 07:33:32 -05:00
|
|
|
streamType !== undefined
|
|
|
|
|
? streamType !== "no-streaming"
|
|
|
|
|
: undefined;
|
2025-02-10 10:42:35 -06:00
|
|
|
const showStillWithoutActivity =
|
|
|
|
|
currentGroupStreamingSettings?.[camera.name]?.streamType !==
|
|
|
|
|
"continuous";
|
|
|
|
|
const useWebGL =
|
|
|
|
|
currentGroupStreamingSettings?.[camera.name]
|
|
|
|
|
?.compatibilityMode || false;
|
2024-05-07 09:28:10 -05:00
|
|
|
return (
|
2025-02-10 10:42:35 -06:00
|
|
|
<GridLiveContextMenu
|
2026-09-27 18:09:31 -05:00
|
|
|
className={CARD_FIT}
|
|
|
|
|
aspectRatio={
|
|
|
|
|
(naturalAspectLayout
|
|
|
|
|
? liveAspects[camera.name]
|
|
|
|
|
: undefined) ??
|
|
|
|
|
layoutAspects[camera.name] ??
|
|
|
|
|
DEFAULT_ASPECT
|
|
|
|
|
}
|
2024-05-07 09:28:10 -05:00
|
|
|
key={camera.name}
|
2025-02-10 10:42:35 -06:00
|
|
|
camera={camera.name}
|
|
|
|
|
streamName={streamName}
|
|
|
|
|
cameraGroup={cameraGroup}
|
2024-06-29 10:02:30 -05:00
|
|
|
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
|
2025-02-10 10:42:35 -06:00
|
|
|
isRestreamed={isRestreamedStates[camera.name]}
|
|
|
|
|
supportsAudio={
|
2025-04-02 07:43:28 -05:00
|
|
|
supportsAudioOutputStates[streamName]?.supportsAudio ??
|
|
|
|
|
false
|
2025-02-10 10:42:35 -06:00
|
|
|
}
|
|
|
|
|
audioState={audioStates[camera.name]}
|
|
|
|
|
toggleAudio={() => toggleAudio(camera.name)}
|
|
|
|
|
statsState={statsStates[camera.name]}
|
|
|
|
|
toggleStats={() => toggleStats(camera.name)}
|
2026-11-01 07:29:16 -06:00
|
|
|
volumeState={volumeStates[camera.name] ?? 1}
|
2025-02-10 10:42:35 -06:00
|
|
|
setVolumeState={(value) =>
|
2026-03-06 14:41:15 -06:00
|
|
|
setVolumeStates((prev) => ({
|
|
|
|
|
...prev,
|
2025-02-10 10:42:35 -06:00
|
|
|
[camera.name]: value,
|
2026-03-06 14:41:15 -06:00
|
|
|
}))
|
2025-02-10 10:42:35 -06:00
|
|
|
}
|
|
|
|
|
muteAll={muteAll}
|
|
|
|
|
unmuteAll={unmuteAll}
|
|
|
|
|
resetPreferredLiveMode={() =>
|
|
|
|
|
resetPreferredLiveMode(camera.name)
|
|
|
|
|
}
|
2025-02-17 08:19:03 -06:00
|
|
|
config={config}
|
2025-11-30 06:54:42 -06:00
|
|
|
streamMetadata={streamMetadata}
|
2024-05-07 09:28:10 -05:00
|
|
|
>
|
2025-02-10 10:42:35 -06:00
|
|
|
<LivePlayer
|
|
|
|
|
key={camera.name}
|
|
|
|
|
streamName={streamName}
|
|
|
|
|
autoLive={autoLive ?? globalAutoLive}
|
|
|
|
|
showStillWithoutActivity={showStillWithoutActivity ?? true}
|
2025-10-29 14:20:11 +00:00
|
|
|
alwaysShowCameraName={displayCameraNames}
|
2025-02-10 10:42:35 -06:00
|
|
|
useWebGL={useWebGL}
|
|
|
|
|
cameraRef={cameraRef}
|
|
|
|
|
className={cn(
|
2026-09-27 18:09:31 -05:00
|
|
|
"size-full",
|
|
|
|
|
naturalAspectLayout ? "bg-background" : "bg-black",
|
2025-02-10 10:42:35 -06:00
|
|
|
isEditMode &&
|
|
|
|
|
showCircles &&
|
|
|
|
|
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
|
|
|
|
)}
|
|
|
|
|
windowVisible={
|
|
|
|
|
windowVisible && visibleCameras.includes(camera.name)
|
|
|
|
|
}
|
|
|
|
|
cameraConfig={camera}
|
|
|
|
|
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
|
|
|
|
|
playInBackground={false}
|
|
|
|
|
showStats={statsStates[camera.name]}
|
|
|
|
|
onClick={() => {
|
2026-09-14 08:53:45 -05:00
|
|
|
if (!isEditMode) {
|
|
|
|
|
onSelectCamera(camera.name);
|
|
|
|
|
}
|
2025-02-10 10:42:35 -06:00
|
|
|
}}
|
2026-09-16 09:12:06 -05:00
|
|
|
onError={(e) => handleError(camera.name, e)}
|
2025-02-10 10:42:35 -06:00
|
|
|
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
|
|
|
|
playAudio={audioStates[camera.name]}
|
2026-11-01 07:29:16 -06:00
|
|
|
volume={volumeStates[camera.name] ?? 1}
|
2026-09-27 18:09:31 -05:00
|
|
|
onLiveAspectChange={liveAspectHandlers[camera.name]}
|
2025-02-10 10:42:35 -06:00
|
|
|
/>
|
|
|
|
|
</GridLiveContextMenu>
|
2024-05-07 09:28:10 -05:00
|
|
|
);
|
|
|
|
|
})}
|
2026-03-05 08:42:38 -06:00
|
|
|
</Responsive>
|
2024-05-10 11:54:37 -05:00
|
|
|
{isDesktop && (
|
2024-05-09 16:08:22 -05:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"fixed",
|
|
|
|
|
isDesktop && "bottom-12 lg:bottom-9",
|
|
|
|
|
isMobile && "bottom-12 lg:bottom-16",
|
|
|
|
|
hasScrollbar && isDesktop ? "right-6" : "right-3",
|
|
|
|
|
"z-50 flex flex-row gap-2",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
2024-05-10 11:54:37 -05:00
|
|
|
<div
|
2026-09-27 18:09:31 -05:00
|
|
|
data-testid="toggle-edit-layout"
|
2024-05-14 10:06:44 -05:00
|
|
|
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
|
2024-05-09 16:08:22 -05:00
|
|
|
onClick={() =>
|
|
|
|
|
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{isEditMode ? (
|
2024-05-10 11:54:37 -05:00
|
|
|
<IoClose className="size-5 md:m-[6px]" />
|
2024-05-09 16:08:22 -05:00
|
|
|
) : (
|
2024-05-10 11:54:37 -05:00
|
|
|
<LuLayoutDashboard className="size-5 md:m-[6px]" />
|
2024-05-09 16:08:22 -05:00
|
|
|
)}
|
2024-05-10 11:54:37 -05:00
|
|
|
</div>
|
2024-05-09 16:08:22 -05:00
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
2025-03-16 23:36:20 +08:00
|
|
|
{isEditMode
|
|
|
|
|
? t("editLayout.exitEdit")
|
|
|
|
|
: t("editLayout.label")}
|
2024-05-09 16:08:22 -05:00
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
{!isEditMode && (
|
|
|
|
|
<>
|
2026-10-10 12:24:00 -05:00
|
|
|
{!fullscreen && isAdmin && (
|
2024-05-31 07:58:33 -05:00
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<div
|
|
|
|
|
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
setEditGroup((prevEditGroup) => !prevEditGroup)
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<LuPencil className="size-5 md:m-[6px]" />
|
|
|
|
|
</div>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
2025-03-16 23:36:20 +08:00
|
|
|
{isEditMode
|
|
|
|
|
? t("editLayout.exitEdit")
|
|
|
|
|
: t("editLayout.group.label")}
|
2024-05-31 07:58:33 -05:00
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
2024-05-09 16:08:22 -05:00
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
2024-05-10 11:54:37 -05:00
|
|
|
<div
|
2024-05-14 10:06:44 -05:00
|
|
|
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
|
2024-05-10 11:54:37 -05:00
|
|
|
onClick={toggleFullscreen}
|
2024-05-09 16:08:22 -05:00
|
|
|
>
|
|
|
|
|
{fullscreen ? (
|
2024-05-10 11:54:37 -05:00
|
|
|
<FaCompress className="size-5 md:m-[6px]" />
|
2024-05-09 16:08:22 -05:00
|
|
|
) : (
|
2024-05-10 11:54:37 -05:00
|
|
|
<FaExpand className="size-5 md:m-[6px]" />
|
2024-05-09 16:08:22 -05:00
|
|
|
)}
|
2024-05-10 11:54:37 -05:00
|
|
|
</div>
|
2024-05-09 16:08:22 -05:00
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
2025-03-16 23:36:20 +08:00
|
|
|
{fullscreen
|
|
|
|
|
? t("button.exitFullscreen", { ns: "common" })
|
|
|
|
|
: t("button.fullscreen", { ns: "common" })}
|
2024-05-09 16:08:22 -05:00
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2024-05-08 07:53:22 -05:00
|
|
|
)}
|
2024-05-07 09:28:10 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type BirdseyeLivePlayerGridItemProps = {
|
|
|
|
|
style?: React.CSSProperties;
|
|
|
|
|
className?: string;
|
|
|
|
|
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
|
|
|
|
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
|
|
|
|
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
|
|
|
|
children?: React.ReactNode;
|
|
|
|
|
birdseyeConfig: BirdseyeConfig;
|
2026-09-27 18:09:31 -05:00
|
|
|
aspectRatio: number;
|
2024-05-07 09:28:10 -05:00
|
|
|
liveMode: LivePlayerMode;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const BirdseyeLivePlayerGridItem = React.forwardRef<
|
|
|
|
|
HTMLDivElement,
|
|
|
|
|
BirdseyeLivePlayerGridItemProps
|
|
|
|
|
>(
|
|
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
style,
|
|
|
|
|
className,
|
|
|
|
|
onMouseDown,
|
|
|
|
|
onMouseUp,
|
|
|
|
|
onTouchEnd,
|
|
|
|
|
children,
|
|
|
|
|
birdseyeConfig,
|
2026-09-27 18:09:31 -05:00
|
|
|
aspectRatio: cellAspect,
|
2024-05-07 09:28:10 -05:00
|
|
|
liveMode,
|
|
|
|
|
onClick,
|
|
|
|
|
...props
|
|
|
|
|
},
|
|
|
|
|
ref,
|
|
|
|
|
) => {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
2026-09-27 18:09:31 -05:00
|
|
|
className="flex items-center justify-center p-1 [container-type:size]"
|
|
|
|
|
style={{ ...style, "--ar": cellAspect } as React.CSSProperties}
|
2024-05-07 09:28:10 -05:00
|
|
|
ref={ref}
|
|
|
|
|
onMouseDown={onMouseDown}
|
|
|
|
|
onMouseUp={onMouseUp}
|
|
|
|
|
onTouchEnd={onTouchEnd}
|
|
|
|
|
{...props}
|
|
|
|
|
>
|
|
|
|
|
<BirdseyeLivePlayer
|
2026-09-27 18:09:31 -05:00
|
|
|
className={cn(CARD_FIT, className)}
|
2024-05-07 09:28:10 -05:00
|
|
|
birdseyeConfig={birdseyeConfig}
|
|
|
|
|
liveMode={liveMode}
|
|
|
|
|
onClick={onClick}
|
2024-05-29 13:05:28 -05:00
|
|
|
containerRef={ref as React.RefObject<HTMLDivElement>}
|
2024-05-07 09:28:10 -05:00
|
|
|
/>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2025-02-10 10:42:35 -06:00
|
|
|
type GridLiveContextMenuProps = {
|
|
|
|
|
className?: string;
|
2024-05-07 09:28:10 -05:00
|
|
|
style?: React.CSSProperties;
|
2026-09-27 18:09:31 -05:00
|
|
|
aspectRatio?: number;
|
2024-05-07 09:28:10 -05:00
|
|
|
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
|
|
|
|
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
|
|
|
|
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
|
|
|
|
children?: React.ReactNode;
|
2025-02-10 10:42:35 -06:00
|
|
|
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;
|
2025-02-17 08:19:03 -06:00
|
|
|
config?: FrigateConfig;
|
2025-11-30 06:54:42 -06:00
|
|
|
streamMetadata?: { [key: string]: LiveStreamMetadata };
|
2024-05-07 09:28:10 -05:00
|
|
|
};
|
|
|
|
|
|
2025-02-10 10:42:35 -06:00
|
|
|
const GridLiveContextMenu = React.forwardRef<
|
2024-05-07 09:28:10 -05:00
|
|
|
HTMLDivElement,
|
2025-02-10 10:42:35 -06:00
|
|
|
GridLiveContextMenuProps
|
2024-05-07 09:28:10 -05:00
|
|
|
>(
|
|
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
className,
|
2025-02-10 10:42:35 -06:00
|
|
|
style,
|
2026-09-27 18:09:31 -05:00
|
|
|
aspectRatio: cameraAspect,
|
2024-05-07 09:28:10 -05:00
|
|
|
onMouseDown,
|
|
|
|
|
onMouseUp,
|
|
|
|
|
onTouchEnd,
|
|
|
|
|
children,
|
2025-02-10 10:42:35 -06:00
|
|
|
camera,
|
|
|
|
|
streamName,
|
|
|
|
|
cameraGroup,
|
2024-05-07 09:28:10 -05:00
|
|
|
preferredLiveMode,
|
2025-02-10 10:42:35 -06:00
|
|
|
isRestreamed,
|
|
|
|
|
supportsAudio,
|
|
|
|
|
audioState,
|
|
|
|
|
toggleAudio,
|
|
|
|
|
statsState,
|
|
|
|
|
toggleStats,
|
|
|
|
|
volumeState,
|
|
|
|
|
setVolumeState,
|
|
|
|
|
muteAll,
|
|
|
|
|
unmuteAll,
|
|
|
|
|
resetPreferredLiveMode,
|
2025-02-17 08:19:03 -06:00
|
|
|
config,
|
2025-11-30 06:54:42 -06:00
|
|
|
streamMetadata,
|
2024-05-07 09:28:10 -05:00
|
|
|
...props
|
|
|
|
|
},
|
|
|
|
|
ref,
|
|
|
|
|
) => {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
2026-09-27 18:09:31 -05:00
|
|
|
className="flex items-center justify-center p-1 [container-type:size]"
|
|
|
|
|
style={{ ...style, "--ar": cameraAspect } as React.CSSProperties}
|
2024-05-07 09:28:10 -05:00
|
|
|
ref={ref}
|
|
|
|
|
onMouseDown={onMouseDown}
|
|
|
|
|
onMouseUp={onMouseUp}
|
|
|
|
|
onTouchEnd={onTouchEnd}
|
|
|
|
|
{...props}
|
|
|
|
|
>
|
2025-02-10 10:42:35 -06:00
|
|
|
<LiveContextMenu
|
2024-05-07 09:28:10 -05:00
|
|
|
className={className}
|
2025-02-10 10:42:35 -06:00
|
|
|
camera={camera}
|
|
|
|
|
streamName={streamName}
|
|
|
|
|
cameraGroup={cameraGroup}
|
2024-05-07 09:28:10 -05:00
|
|
|
preferredLiveMode={preferredLiveMode}
|
2025-02-10 10:42:35 -06:00
|
|
|
isRestreamed={isRestreamed}
|
|
|
|
|
supportsAudio={supportsAudio}
|
|
|
|
|
audioState={audioState}
|
|
|
|
|
toggleAudio={toggleAudio}
|
|
|
|
|
statsState={statsState}
|
|
|
|
|
toggleStats={toggleStats}
|
|
|
|
|
volumeState={volumeState}
|
|
|
|
|
setVolumeState={setVolumeState}
|
|
|
|
|
muteAll={muteAll}
|
|
|
|
|
unmuteAll={unmuteAll}
|
|
|
|
|
resetPreferredLiveMode={resetPreferredLiveMode}
|
2025-02-17 08:19:03 -06:00
|
|
|
config={config}
|
2025-11-30 06:54:42 -06:00
|
|
|
streamMetadata={streamMetadata}
|
2025-02-10 10:42:35 -06:00
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</LiveContextMenu>
|
2024-05-07 09:28:10 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|