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:
Claude 2026-03-21 10:17:13 +00:00
parent 81f3619542
commit be1a19bccd
No known key found for this signature in database
2 changed files with 135 additions and 10 deletions

View File

@ -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.",

View File

@ -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