mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-05 22:57:40 +03:00
Add Fit to Screen mode to Live view grid
Adds a toggle button to the Live view toolbar that automatically arranges all cameras to fit within the viewport without scrolling. Uses a brute-force algorithm to find the optimal number of columns that maximizes camera size while keeping all cameras visible. State persists via IndexedDB. https://claude.ai/code/session_01Cu7YDRKZrYX3sBs6g9w2dy
This commit is contained in:
parent
81f3619542
commit
be1a19bccd
@ -174,6 +174,10 @@
|
||||
},
|
||||
"exitEdit": "Exit Editing"
|
||||
},
|
||||
"fitToScreen": {
|
||||
"enable": "Fit All Cameras to Screen",
|
||||
"disable": "Disable Fit to Screen"
|
||||
},
|
||||
"noCameras": {
|
||||
"title": "No Cameras Configured",
|
||||
"description": "Get started by connecting a camera to Frigate.",
|
||||
|
||||
@ -39,7 +39,7 @@ 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, LuPencil } from "react-icons/lu";
|
||||
import { LuLayoutDashboard, LuMaximize, LuPencil } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EditGroupDialog } from "@/components/filter/CameraGroupSelector";
|
||||
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
@ -132,6 +132,11 @@ export default function DraggableGridLayout({
|
||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] =
|
||||
useUserPersistence<Layout>(`${cameraGroup}-draggable-layout`);
|
||||
|
||||
const [fitToScreen, setFitToScreen] = useUserPersistence(
|
||||
"liveFitToScreen",
|
||||
false,
|
||||
);
|
||||
|
||||
const [group] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
@ -360,10 +365,108 @@ export default function DraggableGridLayout({
|
||||
}
|
||||
}, [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]);
|
||||
}, [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 activeGridLayout = fitToScreen && fitLayout ? fitLayout : currentGridLayout;
|
||||
|
||||
const handleResize = (
|
||||
_layout: Layout,
|
||||
@ -718,11 +821,11 @@ export default function DraggableGridLayout({
|
||||
className="grid-layout"
|
||||
width={availableWidth}
|
||||
layouts={{
|
||||
lg: currentGridLayout,
|
||||
md: currentGridLayout,
|
||||
sm: currentGridLayout,
|
||||
xs: currentGridLayout,
|
||||
xxs: currentGridLayout,
|
||||
lg: activeGridLayout,
|
||||
md: activeGridLayout,
|
||||
sm: activeGridLayout,
|
||||
xs: activeGridLayout,
|
||||
xxs: activeGridLayout,
|
||||
}}
|
||||
rowHeight={cellHeight}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
@ -730,11 +833,11 @@ export default function DraggableGridLayout({
|
||||
margin={[0, 0]}
|
||||
containerPadding={[0, 0]}
|
||||
resizeConfig={{
|
||||
enabled: isEditMode,
|
||||
handles: isEditMode ? ["sw", "nw", "se", "ne"] : [],
|
||||
enabled: isEditMode && !fitToScreen,
|
||||
handles: isEditMode && !fitToScreen ? ["sw", "nw", "se", "ne"] : [],
|
||||
}}
|
||||
dragConfig={{
|
||||
enabled: isEditMode,
|
||||
enabled: isEditMode && !fitToScreen,
|
||||
}}
|
||||
onDragStop={handleLayoutChange}
|
||||
onResize={handleResize}
|
||||
@ -988,6 +1091,24 @@ export default function DraggableGridLayout({
|
||||
</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)}
|
||||
>
|
||||
<LuMaximize className="size-5 md:m-[6px]" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{fitToScreen
|
||||
? t("fitToScreen.disable")
|
||||
: t("fitToScreen.enable")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
|
||||
Loading…
Reference in New Issue
Block a user