mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-05 14:47:40 +03:00
All LayoutItem args are nullable (LayoutItem | null) and event is Event not MouseEvent — matching the actual react-grid-layout EventCallback type. fitDragRef simplified to string | null. Cast event to MouseEvent inside the handler to access clientX/Y for pixel-accurate slot detection. https://claude.ai/code/session_01Cu7YDRKZrYX3sBs6g9w2dy
1419 lines
46 KiB
TypeScript
1419 lines
46 KiB
TypeScript
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 {
|
|
Layout,
|
|
LayoutItem,
|
|
ResponsiveGridLayout as Responsive,
|
|
} from "react-grid-layout";
|
|
import "react-grid-layout/css/styles.css";
|
|
import "react-resizable/css/styles.css";
|
|
import {
|
|
AudioState,
|
|
LivePlayerMode,
|
|
LiveStreamMetadata,
|
|
PlayerStatsType,
|
|
StatsState,
|
|
VolumeState,
|
|
} from "@/types/live";
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
import { PlayerStats } from "@/components/player/PlayerStats";
|
|
import { MdCircle } from "react-icons/md";
|
|
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
import { isEqual } from "lodash";
|
|
import useSWR from "swr";
|
|
import { isDesktop, isMobile } from "react-device-detect";
|
|
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
|
import LivePlayer from "@/components/player/LivePlayer";
|
|
import { IoClose, IoStatsChart } from "react-icons/io5";
|
|
import { LuLayoutDashboard, LuScanBarcode, 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";
|
|
import {
|
|
CAMERA_ZOOM_MIN_SCALE,
|
|
CameraZoomRuntimeTransform,
|
|
clampScale,
|
|
fromPersistedCameraZoomState,
|
|
getCursorRelativeZoomTransform,
|
|
getNextScaleFromWheelDelta,
|
|
loadPersistedCameraZoomState,
|
|
savePersistedCameraZoomState,
|
|
toPersistedCameraZoomState,
|
|
} from "@/utils/cameraZoom";
|
|
|
|
type DraggableGridLayoutProps = {
|
|
cameras: CameraConfig[];
|
|
cameraGroup: string;
|
|
cameraRef: (node: HTMLElement | null) => void;
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
includeBirdseye: boolean;
|
|
onSelectCamera: (camera: string) => void;
|
|
windowVisible: boolean;
|
|
visibleCameras: string[];
|
|
isEditMode: boolean;
|
|
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
|
|
fullscreen: boolean;
|
|
toggleFullscreen: () => void;
|
|
preferredLiveModes: { [key: string]: LivePlayerMode };
|
|
setPreferredLiveModes: React.Dispatch<
|
|
React.SetStateAction<{ [key: string]: LivePlayerMode }>
|
|
>;
|
|
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,
|
|
setPreferredLiveModes,
|
|
resetPreferredLiveMode,
|
|
isRestreamedStates,
|
|
supportsAudioOutputStates,
|
|
streamMetadata,
|
|
}: DraggableGridLayoutProps) {
|
|
const { t } = useTranslation(["views/live"]);
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
|
|
|
// 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<Layout>(`${cameraGroup}-draggable-layout`);
|
|
|
|
const [fitToScreen, setFitToScreen] = useUserPersistence(
|
|
"liveFitToScreen",
|
|
false,
|
|
);
|
|
|
|
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);
|
|
// Reset camera tracking state when group changes to prevent the camera-change
|
|
// effect from incorrectly overwriting the loaded layout
|
|
setCurrentCameras(undefined);
|
|
setCurrentIncludeBirdseye(undefined);
|
|
setCurrentGridLayout(undefined);
|
|
}, [cameraGroup, setIsEditMode]);
|
|
|
|
// camera state
|
|
|
|
const [currentCameras, setCurrentCameras] = useState<CameraConfig[]>();
|
|
const [currentIncludeBirdseye, setCurrentIncludeBirdseye] =
|
|
useState<boolean>();
|
|
const [currentGridLayout, setCurrentGridLayout] = useState<
|
|
Layout | undefined
|
|
>();
|
|
|
|
const handleLayoutChange = useCallback(
|
|
(currentLayout: Layout) => {
|
|
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) {
|
|
return;
|
|
}
|
|
// save layout to idb
|
|
setGridLayout(currentLayout);
|
|
setShowCircles(true);
|
|
},
|
|
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
|
);
|
|
|
|
const generateLayout = useCallback(
|
|
(baseLayout: Layout | undefined) => {
|
|
if (!isGridLayoutLoaded) {
|
|
return;
|
|
}
|
|
|
|
const cameraNames =
|
|
includeBirdseye && birdseyeConfig?.enabled
|
|
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
|
: cameras.map((camera) => camera?.name || "");
|
|
|
|
const optionsMap: LayoutItem[] = baseLayout
|
|
? baseLayout.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 {
|
|
aspectRatio = 16 / 9;
|
|
col = index % 3; // Regular cameras distributed across columns
|
|
}
|
|
|
|
// Calculate layout options based on aspect ratio
|
|
const columnsPerPlayer = 4;
|
|
let height;
|
|
let width;
|
|
|
|
if (aspectRatio < 1) {
|
|
// Portrait
|
|
height = 2 * columnsPerPlayer;
|
|
width = columnsPerPlayer;
|
|
} else if (aspectRatio > 2) {
|
|
// Wide
|
|
height = 1 * columnsPerPlayer;
|
|
width = 2 * columnsPerPlayer;
|
|
} else {
|
|
// Landscape
|
|
height = 1 * columnsPerPlayer;
|
|
width = columnsPerPlayer;
|
|
}
|
|
|
|
const options = {
|
|
i: cameraName,
|
|
x: col * width,
|
|
y: 0, // don't set y, grid does automatically
|
|
w: width,
|
|
h: height,
|
|
};
|
|
|
|
optionsMap.push(options);
|
|
});
|
|
|
|
return optionsMap;
|
|
},
|
|
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (isGridLayoutLoaded) {
|
|
if (gridLayout) {
|
|
// set current grid layout from loaded, possibly adding new cameras
|
|
const updatedLayout = generateLayout(gridLayout);
|
|
setCurrentGridLayout(updatedLayout);
|
|
// Only save if cameras were added (layout changed)
|
|
if (!isEqual(updatedLayout, gridLayout)) {
|
|
setGridLayout(updatedLayout);
|
|
}
|
|
// Set camera tracking state so the camera-change effect has a baseline
|
|
setCurrentCameras(cameras);
|
|
setCurrentIncludeBirdseye(includeBirdseye);
|
|
} else {
|
|
// idb is empty, set it with an initial layout
|
|
const newLayout = generateLayout(undefined);
|
|
setCurrentGridLayout(newLayout);
|
|
setGridLayout(newLayout);
|
|
setCurrentCameras(cameras);
|
|
setCurrentIncludeBirdseye(includeBirdseye);
|
|
}
|
|
}
|
|
}, [
|
|
gridLayout,
|
|
setGridLayout,
|
|
isGridLayoutLoaded,
|
|
generateLayout,
|
|
cameras,
|
|
includeBirdseye,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
// Only regenerate layout when cameras change WITHIN an already-loaded group
|
|
// Skip if currentCameras is undefined (means we just switched groups and
|
|
// the first useEffect hasn't run yet to set things up)
|
|
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!isEqual(cameras, currentCameras) ||
|
|
includeBirdseye !== currentIncludeBirdseye
|
|
) {
|
|
setCurrentCameras(cameras);
|
|
setCurrentIncludeBirdseye(includeBirdseye);
|
|
|
|
// Regenerate layout based on current layout, adding any new cameras
|
|
const updatedLayout = generateLayout(currentGridLayout);
|
|
setCurrentGridLayout(updatedLayout);
|
|
setGridLayout(updatedLayout);
|
|
}
|
|
}, [
|
|
cameras,
|
|
includeBirdseye,
|
|
currentCameras,
|
|
currentIncludeBirdseye,
|
|
currentGridLayout,
|
|
generateLayout,
|
|
setGridLayout,
|
|
isGridLayoutLoaded,
|
|
]);
|
|
|
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
const [containerHeight, setContainerHeight] = useState(0);
|
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
|
|
// Callback ref fires whenever the element mounts/unmounts, so containerWidth
|
|
// is always set immediately when the grid div first appears (after skeleton).
|
|
const gridContainerRef = useCallback((el: HTMLDivElement | null) => {
|
|
roRef.current?.disconnect();
|
|
roRef.current = null;
|
|
if (!el) return;
|
|
setContainerWidth(el.clientWidth);
|
|
setContainerHeight(el.clientHeight);
|
|
const ro = new ResizeObserver(([entry]) => {
|
|
setContainerWidth(entry.contentRect.width);
|
|
setContainerHeight(entry.contentRect.height);
|
|
});
|
|
ro.observe(el);
|
|
roRef.current = ro;
|
|
}, []);
|
|
|
|
const scrollBarWidth = useMemo(() => {
|
|
if (containerWidth && containerHeight && containerRef.current) {
|
|
return (
|
|
containerRef.current.offsetWidth - containerRef.current.clientWidth
|
|
);
|
|
}
|
|
return 0;
|
|
}, [containerRef, containerHeight, containerWidth]);
|
|
|
|
const availableWidth = useMemo(
|
|
() => (scrollBarWidth ? containerWidth + scrollBarWidth : containerWidth),
|
|
[containerWidth, scrollBarWidth],
|
|
);
|
|
|
|
const hasScrollbar = useMemo(() => {
|
|
if (containerHeight && containerRef.current) {
|
|
return (
|
|
containerRef.current.offsetHeight < containerRef.current.scrollHeight
|
|
);
|
|
}
|
|
}, [containerRef, containerHeight]);
|
|
|
|
const [viewportHeight, setViewportHeight] = useState(0);
|
|
const viewportRoRef = useRef<ResizeObserver | null>(null);
|
|
|
|
useEffect(() => {
|
|
viewportRoRef.current?.disconnect();
|
|
viewportRoRef.current = null;
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
setViewportHeight(el.clientHeight);
|
|
const ro = new ResizeObserver(([entry]) => {
|
|
setViewportHeight(entry.contentRect.height);
|
|
});
|
|
ro.observe(el);
|
|
viewportRoRef.current = ro;
|
|
return () => ro.disconnect();
|
|
}, [containerRef]);
|
|
|
|
const totalCameras = useMemo(() => {
|
|
let count = cameras.length;
|
|
if (includeBirdseye && birdseyeConfig?.enabled) count += 1;
|
|
return count;
|
|
}, [cameras, includeBirdseye, birdseyeConfig]);
|
|
|
|
const fitGridParams = useMemo(() => {
|
|
if (!fitToScreen || !availableWidth || !viewportHeight || totalCameras === 0) {
|
|
return null;
|
|
}
|
|
|
|
const aspectRatio = 16 / 9;
|
|
let bestCols = 1;
|
|
let bestScore = 0;
|
|
|
|
for (let cols = 1; cols <= Math.min(totalCameras, 12); cols++) {
|
|
const w = Math.floor(12 / cols);
|
|
if (w < 1) continue;
|
|
const rows = Math.ceil(totalCameras / cols);
|
|
const camWidth = (w / 12) * availableWidth;
|
|
const camHeight = camWidth / aspectRatio;
|
|
const totalHeight = camHeight * rows;
|
|
|
|
if (totalHeight <= viewportHeight) {
|
|
const score = camWidth * camHeight;
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestCols = cols;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bestScore === 0) {
|
|
let minOvershoot = Infinity;
|
|
for (let cols = 1; cols <= Math.min(totalCameras, 12); cols++) {
|
|
const w = Math.floor(12 / cols);
|
|
if (w < 1) continue;
|
|
const rows = Math.ceil(totalCameras / cols);
|
|
const camWidth = (w / 12) * availableWidth;
|
|
const camHeight = camWidth / aspectRatio;
|
|
const totalHeight = camHeight * rows;
|
|
if (totalHeight - viewportHeight < minOvershoot) {
|
|
minOvershoot = totalHeight - viewportHeight;
|
|
bestCols = cols;
|
|
}
|
|
}
|
|
}
|
|
|
|
const gridUnitsPerCam = Math.floor(12 / bestCols);
|
|
const rows = Math.ceil(totalCameras / bestCols);
|
|
const fittedCellH = viewportHeight / (rows * gridUnitsPerCam);
|
|
|
|
return { gridUnitsPerCam, colsPerRow: bestCols, cellHeight: fittedCellH };
|
|
}, [fitToScreen, availableWidth, viewportHeight, totalCameras]);
|
|
|
|
const cellHeight = useMemo(() => {
|
|
if (fitGridParams) {
|
|
return fitGridParams.cellHeight;
|
|
}
|
|
const aspectRatio = 16 / 9;
|
|
return availableWidth / 12 / aspectRatio;
|
|
}, [availableWidth, fitGridParams]);
|
|
|
|
const fitLayout = useMemo(() => {
|
|
if (!fitToScreen || !fitGridParams) return null;
|
|
|
|
const cameraNames =
|
|
includeBirdseye && birdseyeConfig?.enabled
|
|
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
|
: cameras.map((camera) => camera?.name || "");
|
|
|
|
const w = fitGridParams.gridUnitsPerCam;
|
|
const h = w;
|
|
const colsPerRow = fitGridParams.colsPerRow;
|
|
|
|
return cameraNames.map((name, index) => ({
|
|
i: name,
|
|
x: (index % colsPerRow) * w,
|
|
y: Math.floor(index / colsPerRow) * h,
|
|
w,
|
|
h,
|
|
}));
|
|
}, [fitToScreen, fitGridParams, cameras, includeBirdseye, birdseyeConfig]);
|
|
|
|
const [fitLayoutOverride, setFitLayoutOverride] = useState<Layout | undefined>();
|
|
const fitDragRef = useRef<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setFitLayoutOverride(undefined);
|
|
}, [fitGridParams, cameras, includeBirdseye]);
|
|
|
|
const handleFitDrag = useCallback(
|
|
(
|
|
_layout: Layout,
|
|
_oldItem: LayoutItem | null,
|
|
newItem: LayoutItem | null,
|
|
) => {
|
|
if (newItem) {
|
|
fitDragRef.current = newItem.i;
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleFitDragStop = useCallback(
|
|
(
|
|
_layout: Layout,
|
|
_oldItem: LayoutItem | null,
|
|
newItem: LayoutItem | null,
|
|
_placeholder: LayoutItem | null,
|
|
event: Event,
|
|
) => {
|
|
if (!fitToScreen || !fitGridParams) return;
|
|
|
|
const w = fitGridParams.gridUnitsPerCam;
|
|
const colsPerRow = fitGridParams.colsPerRow;
|
|
const draggedId = fitDragRef.current ?? newItem?.i;
|
|
fitDragRef.current = null;
|
|
|
|
if (!draggedId) return;
|
|
|
|
const currentOrder = fitLayoutOverride ?? fitLayout ?? [];
|
|
const orderedNames = [...currentOrder]
|
|
.sort((a, b) => {
|
|
if (a.y !== b.y) return a.y - b.y;
|
|
return a.x - b.x;
|
|
})
|
|
.map((item) => item.i);
|
|
|
|
const gridEl = containerRef.current?.querySelector(
|
|
".grid-layout",
|
|
) as HTMLElement | null;
|
|
if (!gridEl) return;
|
|
|
|
const mouseEvent = event as MouseEvent;
|
|
const rect = gridEl.getBoundingClientRect();
|
|
const mouseRelX = mouseEvent.clientX - rect.left;
|
|
const mouseRelY = mouseEvent.clientY - rect.top;
|
|
|
|
const cellWidthPx = rect.width / colsPerRow;
|
|
const cellHeightPx = cellHeight * w;
|
|
|
|
const targetCol = Math.max(
|
|
0,
|
|
Math.min(Math.floor(mouseRelX / cellWidthPx), colsPerRow - 1),
|
|
);
|
|
const targetRow = Math.max(0, Math.floor(mouseRelY / cellHeightPx));
|
|
const totalRows = Math.ceil(orderedNames.length / colsPerRow);
|
|
const clampedRow = Math.min(targetRow, totalRows - 1);
|
|
const targetIndex = Math.min(
|
|
clampedRow * colsPerRow + targetCol,
|
|
orderedNames.length - 1,
|
|
);
|
|
|
|
const sourceIndex = orderedNames.indexOf(draggedId);
|
|
if (sourceIndex === -1 || sourceIndex === targetIndex) {
|
|
setFitLayoutOverride((prev) => (prev ? [...prev] : prev));
|
|
return;
|
|
}
|
|
|
|
const newOrder = [...orderedNames];
|
|
[newOrder[sourceIndex], newOrder[targetIndex]] = [
|
|
newOrder[targetIndex],
|
|
newOrder[sourceIndex],
|
|
];
|
|
|
|
const normalized = newOrder.map((name, index) => ({
|
|
i: name,
|
|
x: (index % colsPerRow) * w,
|
|
y: Math.floor(index / colsPerRow) * w,
|
|
w,
|
|
h: w,
|
|
}));
|
|
|
|
setFitLayoutOverride(normalized);
|
|
},
|
|
[fitToScreen, fitGridParams, fitLayoutOverride, fitLayout, cellHeight, containerRef],
|
|
);
|
|
|
|
const activeGridLayout = useMemo(() => {
|
|
if (fitToScreen) {
|
|
return fitLayoutOverride ?? fitLayout ?? currentGridLayout;
|
|
}
|
|
return currentGridLayout;
|
|
}, [fitToScreen, fitLayoutOverride, fitLayout, currentGridLayout]);
|
|
|
|
const handleResize = (
|
|
_layout: Layout,
|
|
oldLayoutItem: LayoutItem | null,
|
|
layoutItem: LayoutItem | null,
|
|
placeholder: LayoutItem | null,
|
|
) => {
|
|
if (!oldLayoutItem || !layoutItem || !placeholder) return;
|
|
|
|
const heightDiff = layoutItem.h - oldLayoutItem.h;
|
|
const widthDiff = layoutItem.w - oldLayoutItem.w;
|
|
const changeCoef = oldLayoutItem.w / oldLayoutItem.h;
|
|
|
|
let newWidth, newHeight;
|
|
|
|
if (Math.abs(heightDiff) < Math.abs(widthDiff)) {
|
|
newHeight = Math.round(layoutItem.w / changeCoef);
|
|
newWidth = Math.round(newHeight * changeCoef);
|
|
} else {
|
|
newWidth = Math.round(layoutItem.h * changeCoef);
|
|
newHeight = Math.round(newWidth / changeCoef);
|
|
}
|
|
|
|
// Ensure dimensions maintain aspect ratio and fit within the grid
|
|
if (layoutItem.x + newWidth > 12) {
|
|
newWidth = 12 - layoutItem.x;
|
|
newHeight = Math.round(newWidth / changeCoef);
|
|
}
|
|
|
|
if (changeCoef == 0.5) {
|
|
// portrait
|
|
newHeight = Math.ceil(newHeight / 2) * 2;
|
|
} else if (changeCoef == 2) {
|
|
// pano/wide
|
|
newHeight = Math.ceil(newHeight * 2) / 2;
|
|
}
|
|
|
|
newWidth = Math.round(newHeight * changeCoef);
|
|
|
|
layoutItem.w = newWidth;
|
|
layoutItem.h = newHeight;
|
|
placeholder.w = layoutItem.w;
|
|
placeholder.h = layoutItem.h;
|
|
};
|
|
|
|
// audio and stats states
|
|
const [globalStreamStatsEnabled, setGlobalStreamStatsEnabled] =
|
|
useState(false);
|
|
|
|
const getStreamStatsFromStorage = (): boolean => {
|
|
const storedValue = localStorage.getItem("globalStreamStatsEnabled");
|
|
return storedValue === "true";
|
|
};
|
|
|
|
const setStreamStatsToStorage = (value: boolean): void => {
|
|
localStorage.setItem("globalStreamStatsEnabled", value.toString());
|
|
};
|
|
|
|
const toggleGlobalStreamStats = () => {
|
|
setGlobalStreamStatsEnabled((prevState) => {
|
|
const newState = !prevState;
|
|
setStreamStatsToStorage(newState);
|
|
return newState;
|
|
});
|
|
};
|
|
|
|
const [audioStates, setAudioStates] = useState<AudioState>({});
|
|
const [volumeStates, setVolumeStates] = useState<VolumeState>({});
|
|
const [statsStates, setStatsStates] = useState<StatsState>({});
|
|
const [cameraZoomStates, setCameraZoomStates] = useState<
|
|
Record<string, CameraZoomRuntimeTransform>
|
|
>({});
|
|
const [cameraStatsData, setCameraStatsData] = useState<
|
|
Record<string, PlayerStatsType>
|
|
>({});
|
|
const [cameraLoadingStates, setCameraLoadingStates] = useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
const cameraZoomViewportRefs = useRef<Record<string, HTMLDivElement | null>>(
|
|
{},
|
|
);
|
|
const cameraZoomWheelCleanupRefs = useRef<Record<string, () => void>>({});
|
|
|
|
const getCardZoomDimensions = useCallback((cameraName: string) => {
|
|
const viewport = cameraZoomViewportRefs.current[cameraName];
|
|
const content = viewport?.firstElementChild as HTMLElement | null;
|
|
const viewportWidth = viewport?.clientWidth ?? 0;
|
|
const viewportHeight = viewport?.clientHeight ?? 0;
|
|
|
|
return {
|
|
viewportWidth,
|
|
viewportHeight,
|
|
contentWidth: content?.clientWidth ?? viewportWidth,
|
|
contentHeight: content?.clientHeight ?? viewportHeight,
|
|
};
|
|
}, []);
|
|
|
|
const hydrateCameraZoomFromStorage = useCallback(
|
|
(cameraName: string) => {
|
|
setCameraZoomStates((prev) => {
|
|
if (prev[cameraName]) {
|
|
return prev;
|
|
}
|
|
|
|
const persisted = loadPersistedCameraZoomState(cameraName);
|
|
if (!persisted) {
|
|
return prev;
|
|
}
|
|
|
|
const dimensions = getCardZoomDimensions(cameraName);
|
|
if (!dimensions.viewportWidth || !dimensions.viewportHeight) {
|
|
return prev;
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
[cameraName]: fromPersistedCameraZoomState(persisted, dimensions),
|
|
};
|
|
});
|
|
},
|
|
[getCardZoomDimensions],
|
|
);
|
|
|
|
const getDefaultZoomTransform = useCallback(
|
|
(): CameraZoomRuntimeTransform => ({
|
|
scale: CAMERA_ZOOM_MIN_SCALE,
|
|
positionX: 0,
|
|
positionY: 0,
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const handleCardWheelZoom = useCallback(
|
|
(
|
|
cameraName: string,
|
|
event: WheelEvent,
|
|
viewportElement: HTMLDivElement,
|
|
) => {
|
|
if (!event.shiftKey) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
const bounds = viewportElement.getBoundingClientRect();
|
|
const cursorX = event.clientX - bounds.left;
|
|
const cursorY = event.clientY - bounds.top;
|
|
|
|
setCameraZoomStates((prev) => {
|
|
const current = prev[cameraName] ?? getDefaultZoomTransform();
|
|
const nextScale = clampScale(
|
|
getNextScaleFromWheelDelta(current.scale, event.deltaY),
|
|
);
|
|
const next = getCursorRelativeZoomTransform(
|
|
current,
|
|
nextScale,
|
|
cursorX,
|
|
cursorY,
|
|
);
|
|
const content = viewportElement.firstElementChild as HTMLElement | null;
|
|
const persisted = toPersistedCameraZoomState(next, {
|
|
viewportWidth: bounds.width,
|
|
viewportHeight: bounds.height,
|
|
contentWidth: content?.clientWidth ?? bounds.width,
|
|
contentHeight: content?.clientHeight ?? bounds.height,
|
|
});
|
|
|
|
savePersistedCameraZoomState(cameraName, persisted);
|
|
|
|
return {
|
|
...prev,
|
|
[cameraName]: next,
|
|
};
|
|
});
|
|
},
|
|
[getDefaultZoomTransform],
|
|
);
|
|
|
|
const detachCardZoomWheelListener = useCallback((cameraName: string) => {
|
|
cameraZoomWheelCleanupRefs.current[cameraName]?.();
|
|
delete cameraZoomWheelCleanupRefs.current[cameraName];
|
|
}, []);
|
|
|
|
const attachCardZoomWheelListener = useCallback(
|
|
(cameraName: string, viewportElement: HTMLDivElement) => {
|
|
detachCardZoomWheelListener(cameraName);
|
|
|
|
const listener = (event: WheelEvent) => {
|
|
handleCardWheelZoom(cameraName, event, viewportElement);
|
|
};
|
|
|
|
viewportElement.addEventListener("wheel", listener, { passive: false });
|
|
|
|
cameraZoomWheelCleanupRefs.current[cameraName] = () => {
|
|
viewportElement.removeEventListener("wheel", listener);
|
|
};
|
|
},
|
|
[detachCardZoomWheelListener, handleCardWheelZoom],
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
Object.values(cameraZoomWheelCleanupRefs.current).forEach((cleanup) =>
|
|
cleanup(),
|
|
);
|
|
cameraZoomWheelCleanupRefs.current = {};
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
cameras.forEach((camera) => {
|
|
hydrateCameraZoomFromStorage(camera.name);
|
|
});
|
|
}, [cameras, hydrateCameraZoomFromStorage]);
|
|
|
|
useEffect(() => {
|
|
const initialStreamStatsState = getStreamStatsFromStorage();
|
|
setGlobalStreamStatsEnabled(initialStreamStatsState);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const updatedStatsState: StatsState = {};
|
|
|
|
cameras.forEach((camera) => {
|
|
updatedStatsState[camera.name] = globalStreamStatsEnabled;
|
|
});
|
|
|
|
setStatsStates(updatedStatsState);
|
|
}, [globalStreamStatsEnabled, cameras]);
|
|
|
|
const toggleStats = (cameraName: string): void => {
|
|
setStatsStates((prev) => ({
|
|
...prev,
|
|
[cameraName]: !prev[cameraName],
|
|
}));
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!allGroupsStreamingSettings) {
|
|
return;
|
|
}
|
|
|
|
const initialAudioStates: AudioState = {};
|
|
const initialVolumeStates: VolumeState = {};
|
|
|
|
Object.entries(allGroupsStreamingSettings).forEach(([_, groupSettings]) => {
|
|
if (groupSettings) {
|
|
Object.entries(groupSettings).forEach(([camera, cameraSettings]) => {
|
|
initialAudioStates[camera] = cameraSettings.playAudio ?? false;
|
|
initialVolumeStates[camera] = cameraSettings.volume ?? 1;
|
|
});
|
|
}
|
|
});
|
|
|
|
setAudioStates(initialAudioStates);
|
|
setVolumeStates(initialVolumeStates);
|
|
}, [allGroupsStreamingSettings]);
|
|
|
|
const toggleAudio = (cameraName: string) => {
|
|
setAudioStates((prev) => ({
|
|
...prev,
|
|
[cameraName]: !prev[cameraName],
|
|
}));
|
|
};
|
|
|
|
const onSaveMuting = useCallback(
|
|
(playAudio: boolean) => {
|
|
if (!cameraGroup || !allGroupsStreamingSettings) {
|
|
return;
|
|
}
|
|
|
|
const existingGroupSettings =
|
|
allGroupsStreamingSettings[cameraGroup] || {};
|
|
|
|
const updatedSettings: AllGroupsStreamingSettings = {
|
|
...Object.fromEntries(
|
|
Object.entries(allGroupsStreamingSettings || {}).filter(
|
|
([key]) => key !== cameraGroup,
|
|
),
|
|
),
|
|
[cameraGroup]: {
|
|
...existingGroupSettings,
|
|
...Object.fromEntries(
|
|
Object.entries(existingGroupSettings).map(
|
|
([cameraName, settings]) => [
|
|
cameraName,
|
|
{
|
|
...settings,
|
|
playAudio: playAudio,
|
|
},
|
|
],
|
|
),
|
|
),
|
|
},
|
|
};
|
|
|
|
setAllGroupsStreamingSettings?.(updatedSettings);
|
|
},
|
|
[cameraGroup, allGroupsStreamingSettings, setAllGroupsStreamingSettings],
|
|
);
|
|
|
|
const muteAll = () => {
|
|
const updatedStates: AudioState = {};
|
|
cameras.forEach((camera) => {
|
|
updatedStates[camera.name] = false;
|
|
});
|
|
setAudioStates(updatedStates);
|
|
onSaveMuting(false);
|
|
};
|
|
|
|
const unmuteAll = () => {
|
|
const updatedStates: AudioState = {};
|
|
cameras.forEach((camera) => {
|
|
updatedStates[camera.name] = true;
|
|
});
|
|
setAudioStates(updatedStates);
|
|
onSaveMuting(true);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Toaster position="top-center" closeButton={true} />
|
|
{!isGridLayoutLoaded ||
|
|
!currentGridLayout ||
|
|
!isEqual(cameras, currentCameras) ||
|
|
includeBirdseye !== currentIncludeBirdseye ? (
|
|
<div className="mt-2 grid grid-cols-2 gap-2 px-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4">
|
|
{includeBirdseye && birdseyeConfig?.enabled && (
|
|
<Skeleton className="size-full rounded-lg md:rounded-2xl" />
|
|
)}
|
|
{cameras.map((camera) => {
|
|
return (
|
|
<Skeleton
|
|
key={camera.name}
|
|
className="aspect-video size-full rounded-lg md:rounded-2xl"
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="no-scrollbar select-none overflow-x-hidden"
|
|
ref={gridContainerRef}
|
|
>
|
|
<EditGroupDialog
|
|
open={editGroup}
|
|
setOpen={setEditGroup}
|
|
currentGroups={groups}
|
|
activeGroup={group}
|
|
/>
|
|
{containerWidth > 0 && <Responsive
|
|
className="grid-layout"
|
|
width={availableWidth}
|
|
layouts={{
|
|
lg: activeGridLayout,
|
|
md: activeGridLayout,
|
|
sm: activeGridLayout,
|
|
xs: activeGridLayout,
|
|
xxs: activeGridLayout,
|
|
}}
|
|
rowHeight={cellHeight}
|
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
|
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
|
|
margin={[0, 0]}
|
|
containerPadding={[0, 0]}
|
|
resizeConfig={{
|
|
enabled: isEditMode && !fitToScreen,
|
|
handles: isEditMode && !fitToScreen ? ["sw", "nw", "se", "ne"] : [],
|
|
}}
|
|
dragConfig={{
|
|
enabled: isEditMode,
|
|
}}
|
|
onDrag={fitToScreen ? handleFitDrag : undefined}
|
|
onDragStop={fitToScreen ? handleFitDragStop : handleLayoutChange}
|
|
onResize={handleResize}
|
|
onResizeStart={() => setShowCircles(false)}
|
|
onResizeStop={handleLayoutChange}
|
|
>
|
|
{includeBirdseye && birdseyeConfig?.enabled && (
|
|
<BirdseyeLivePlayerGridItem
|
|
key="birdseye"
|
|
className={cn(
|
|
isEditMode &&
|
|
showCircles &&
|
|
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
|
|
)}
|
|
birdseyeConfig={birdseyeConfig}
|
|
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
|
|
onClick={() => onSelectCamera("birdseye")}
|
|
>
|
|
{isEditMode && showCircles && <CornerCircles />}
|
|
</BirdseyeLivePlayerGridItem>
|
|
)}
|
|
{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;
|
|
const cameraZoomTransform =
|
|
cameraZoomStates[camera.name] ?? getDefaultZoomTransform();
|
|
|
|
return (
|
|
<GridLiveContextMenu
|
|
className="size-full"
|
|
key={camera.name}
|
|
camera={camera.name}
|
|
streamName={streamName}
|
|
cameraGroup={cameraGroup}
|
|
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
|
|
isRestreamed={isRestreamedStates[camera.name]}
|
|
supportsAudio={
|
|
supportsAudioOutputStates[streamName]?.supportsAudio ??
|
|
false
|
|
}
|
|
audioState={audioStates[camera.name]}
|
|
toggleAudio={() => toggleAudio(camera.name)}
|
|
statsState={statsStates[camera.name] ?? true}
|
|
toggleStats={() => toggleStats(camera.name)}
|
|
volumeState={volumeStates[camera.name]}
|
|
setVolumeState={(value) =>
|
|
setVolumeStates((prev) => ({
|
|
...prev,
|
|
[camera.name]: value,
|
|
}))
|
|
}
|
|
muteAll={muteAll}
|
|
unmuteAll={unmuteAll}
|
|
resetPreferredLiveMode={() =>
|
|
resetPreferredLiveMode(camera.name)
|
|
}
|
|
config={config}
|
|
streamMetadata={streamMetadata}
|
|
>
|
|
<div
|
|
className="relative size-full overflow-hidden"
|
|
ref={(node) => {
|
|
cameraZoomViewportRefs.current[camera.name] = node;
|
|
|
|
if (!node) {
|
|
detachCardZoomWheelListener(camera.name);
|
|
return;
|
|
}
|
|
|
|
attachCardZoomWheelListener(camera.name, node);
|
|
|
|
hydrateCameraZoomFromStorage(camera.name);
|
|
}}
|
|
>
|
|
<div
|
|
className="size-full"
|
|
style={{
|
|
transform: `translate3d(${cameraZoomTransform.positionX}px, ${cameraZoomTransform.positionY}px, 0) scale(${cameraZoomTransform.scale})`,
|
|
transformOrigin: "top left",
|
|
}}
|
|
>
|
|
<LivePlayer
|
|
key={camera.name}
|
|
streamName={streamName}
|
|
autoLive={autoLive ?? globalAutoLive}
|
|
showStillWithoutActivity={
|
|
showStillWithoutActivity ?? true
|
|
}
|
|
alwaysShowCameraName={displayCameraNames}
|
|
useWebGL={useWebGL}
|
|
cameraRef={cameraRef}
|
|
className={cn(
|
|
"draggable-live-grid-mse-cover size-full bg-black",
|
|
camera.ui?.rotate &&
|
|
"draggable-live-grid-rotated [--frigate-mse-grid-rotated:1] [--frigate-mse-grid-rotation:rotate(90deg)]",
|
|
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={false}
|
|
onStatsUpdate={(stats) =>
|
|
setCameraStatsData((prev) => ({
|
|
...prev,
|
|
[camera.name]: stats,
|
|
}))
|
|
}
|
|
onLoadingChange={(loading) =>
|
|
setCameraLoadingStates((prev) => ({
|
|
...prev,
|
|
[camera.name]: loading,
|
|
}))
|
|
}
|
|
showMotionDot={false}
|
|
onClick={() => {
|
|
!isEditMode && onSelectCamera(camera.name);
|
|
}}
|
|
onError={(e) => {
|
|
setPreferredLiveModes((prevModes) => {
|
|
const newModes = { ...prevModes };
|
|
if (e === "mse-decode") {
|
|
delete newModes[camera.name];
|
|
}
|
|
return newModes;
|
|
});
|
|
}}
|
|
onResetLiveMode={() =>
|
|
resetPreferredLiveMode(camera.name)
|
|
}
|
|
playAudio={audioStates[camera.name]}
|
|
volume={volumeStates[camera.name]}
|
|
/>
|
|
</div>
|
|
{cameraLoadingStates[camera.name] && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<ActivityIndicator />
|
|
</div>
|
|
)}
|
|
{statsStates[camera.name] &&
|
|
cameraStatsData[camera.name] && (
|
|
<PlayerStats
|
|
stats={cameraStatsData[camera.name]}
|
|
minimal={true}
|
|
/>
|
|
)}
|
|
<CameraMotionDot
|
|
camera={camera}
|
|
autoLive={autoLive ?? globalAutoLive}
|
|
/>
|
|
</div>
|
|
{isEditMode && showCircles && <CornerCircles />}
|
|
</GridLiveContextMenu>
|
|
);
|
|
})}
|
|
</Responsive>}
|
|
{isDesktop && (
|
|
<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>
|
|
<div
|
|
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
|
|
onClick={toggleGlobalStreamStats}
|
|
>
|
|
<IoStatsChart className="size-5 md:m-[6px]" />
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{globalStreamStatsEnabled
|
|
? t("streamStats.disable")
|
|
: t("streamStats.enable")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<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={() =>
|
|
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
|
|
}
|
|
>
|
|
{isEditMode ? (
|
|
<IoClose className="size-5 md:m-[6px]" />
|
|
) : (
|
|
<LuLayoutDashboard className="size-5 md:m-[6px]" />
|
|
)}
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{isEditMode
|
|
? t("editLayout.exitEdit")
|
|
: t("editLayout.label")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
{!isEditMode && (
|
|
<>
|
|
{!fullscreen && (
|
|
<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>
|
|
{isEditMode
|
|
? t("editLayout.exitEdit")
|
|
: t("editLayout.group.label")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div
|
|
className={cn(
|
|
"cursor-pointer rounded-lg bg-secondary text-secondary-foreground transition-all duration-300 hover:bg-muted hover:opacity-100",
|
|
fitToScreen ? "opacity-100" : "opacity-60",
|
|
)}
|
|
onClick={() => setFitToScreen(!fitToScreen)}
|
|
>
|
|
<LuScanBarcode className="size-5 md:m-[6px]" />
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{fitToScreen
|
|
? t("fitToScreen.disable")
|
|
: t("fitToScreen.enable")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<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={toggleFullscreen}
|
|
>
|
|
{fullscreen ? (
|
|
<FaCompress className="size-5 md:m-[6px]" />
|
|
) : (
|
|
<FaExpand className="size-5 md:m-[6px]" />
|
|
)}
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{fullscreen
|
|
? t("button.exitFullscreen", { ns: "common" })
|
|
: t("button.fullscreen", { ns: "common" })}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CornerCircles() {
|
|
return (
|
|
<>
|
|
<div className="pointer-events-none absolute left-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
|
<div className="pointer-events-none absolute right-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
|
<div className="pointer-events-none absolute bottom-[-4px] right-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
|
<div className="pointer-events-none absolute bottom-[-4px] left-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
type BirdseyeLivePlayerGridItemProps = {
|
|
style?: React.CSSProperties;
|
|
className?: string;
|
|
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
|
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
|
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
|
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 (
|
|
<div
|
|
style={{ ...style }}
|
|
ref={ref}
|
|
onMouseDown={onMouseDown}
|
|
onMouseUp={onMouseUp}
|
|
onTouchEnd={onTouchEnd}
|
|
{...props}
|
|
>
|
|
<BirdseyeLivePlayer
|
|
className={className}
|
|
birdseyeConfig={birdseyeConfig}
|
|
liveMode={liveMode}
|
|
onClick={onClick}
|
|
containerRef={ref as React.RefObject<HTMLDivElement>}
|
|
/>
|
|
{children}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
// Separate component so it can call useCameraActivity as a hook (no hooks in loops).
|
|
// Direct WS subscription guarantees the dot reacts to motion changes in real-time
|
|
// without relying on an intermediate callback → parent-state chain.
|
|
function CameraMotionDot({
|
|
camera,
|
|
autoLive,
|
|
}: {
|
|
camera: CameraConfig;
|
|
autoLive?: boolean;
|
|
}) {
|
|
const { activeMotion, offline } = useCameraActivity(camera);
|
|
if (autoLive === false || offline || !activeMotion) return null;
|
|
return (
|
|
<div className="absolute right-2 top-2 z-40">
|
|
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type GridLiveContextMenuProps = {
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
|
|
onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
|
|
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
|
|
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,
|
|
onMouseDown,
|
|
onMouseUp,
|
|
onTouchEnd,
|
|
children,
|
|
camera,
|
|
streamName,
|
|
cameraGroup,
|
|
preferredLiveMode,
|
|
isRestreamed,
|
|
supportsAudio,
|
|
audioState,
|
|
toggleAudio,
|
|
statsState,
|
|
toggleStats,
|
|
volumeState,
|
|
setVolumeState,
|
|
muteAll,
|
|
unmuteAll,
|
|
resetPreferredLiveMode,
|
|
config,
|
|
streamMetadata,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
return (
|
|
<div
|
|
style={{ ...style }}
|
|
ref={ref}
|
|
onMouseDown={onMouseDown}
|
|
onMouseUp={onMouseUp}
|
|
onTouchEnd={onTouchEnd}
|
|
{...props}
|
|
>
|
|
<LiveContextMenu
|
|
className={className}
|
|
camera={camera}
|
|
streamName={streamName}
|
|
cameraGroup={cameraGroup}
|
|
preferredLiveMode={preferredLiveMode}
|
|
isRestreamed={isRestreamed}
|
|
supportsAudio={supportsAudio}
|
|
audioState={audioState}
|
|
toggleAudio={toggleAudio}
|
|
statsState={statsState}
|
|
toggleStats={toggleStats}
|
|
volumeState={volumeState}
|
|
setVolumeState={setVolumeState}
|
|
muteAll={muteAll}
|
|
unmuteAll={unmuteAll}
|
|
resetPreferredLiveMode={resetPreferredLiveMode}
|
|
config={config}
|
|
streamMetadata={streamMetadata}
|
|
>
|
|
{children}
|
|
</LiveContextMenu>
|
|
</div>
|
|
);
|
|
},
|
|
);
|