diff --git a/web/e2e/helpers/grid-layout.ts b/web/e2e/helpers/grid-layout.ts new file mode 100644 index 0000000000..a3d8d97e87 --- /dev/null +++ b/web/e2e/helpers/grid-layout.ts @@ -0,0 +1,204 @@ +/** + * Helpers for the live dashboard's draggable grid layout: reading and seeding + * the persisted layout, and measuring rendered tiles. + * + * DraggableGridLayout persists through useUserPersistence, which namespaces + * keys by username, and every write is an async idb put. A test that seeds the + * bare key, or seeds before the app's own first write has landed, silently + * asserts against a key the app never reads. persistedLayoutKey() closes both + * holes, so prefer it over building the key by hand. + * + * Geometry has its own trap: the grid first lays out against window.innerWidth, + * then reflows narrower once useResizeObserver reports the real container. + * Tiles measured in separate round-trips can straddle that reflow and disagree + * on scale, so cameraBoxes() takes every measurement in one evaluate. + * + * Used by live-grid-aspect-modes.spec.ts and masonry-live-grid.spec.ts. + */ + +import { expect, type Page } from "@playwright/test"; + +export type LayoutItem = { + i: string; + x: number; + y: number; + w: number; + h: number; +}; + +export type PersistedLayout = { + version: number; + naturalAspect: boolean; + layout: LayoutItem[]; +}; + +function layoutKeySuffix(group: string): string { + return `${group}-draggable-layout`; +} + +/** + * The key the app has actually written an envelope to, or undefined while its + * first write is still in flight. + */ +function findWrittenKey( + page: Page, + group: string, +): Promise { + return page.evaluate( + (suffix) => + new Promise((resolve) => { + const open = indexedDB.open("keyval-store"); + open.onsuccess = () => { + const store = open.result + .transaction("keyval", "readonly") + .objectStore("keyval"); + // getAllKeys and getAll both return in key order, so the indexes align + const keys = store.getAllKeys(); + const values = store.getAll(); + keys.transaction.oncomplete = () => { + open.result.close(); + const names = keys.result as string[]; + const stored = values.result as { version?: number }[]; + const match = names.findIndex( + (name, index) => + (name === suffix || name.startsWith(`${suffix}:`)) && + typeof stored[index]?.version === "number", + ); + resolve(match === -1 ? undefined : names[match]); + }; + }; + open.onerror = () => resolve(undefined); + }), + layoutKeySuffix(group), + ); +} + +/** + * Wait for the grid to persist its own layout, then return the key it used. + * Waiting for that write is what makes a later seed meaningful: it proves the + * key is live, and it rules out the app overwriting the seed a moment later. + */ +export async function persistedLayoutKey( + page: Page, + group: string, +): Promise { + let key: string | undefined; + + await expect + .poll(async () => (key = await findWrittenKey(page, group)), { + timeout: 10_000, + message: `grid never persisted a layout for group "${group}"`, + }) + .not.toBeUndefined(); + + return key!; +} + +/** Overwrite the stored layout, resolving only once the put has committed. */ +export function seedLayout( + page: Page, + key: string, + value: unknown, +): Promise { + return page.evaluate( + ([key, value]) => + new Promise((resolve, reject) => { + const open = indexedDB.open("keyval-store"); + open.onupgradeneeded = () => open.result.createObjectStore("keyval"); + open.onsuccess = () => { + const tx = open.result.transaction("keyval", "readwrite"); + tx.objectStore("keyval").put(value, key as string); + tx.oncomplete = () => { + open.result.close(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }; + open.onerror = () => reject(open.error); + }), + [key, value] as const, + ); +} + +/** Read the stored layout back. Undefined until the app writes it. */ +export function readLayout( + page: Page, + key: string, +): Promise { + return page.evaluate( + (target) => + new Promise((resolve) => { + const open = indexedDB.open("keyval-store"); + open.onsuccess = () => { + const tx = open.result.transaction("keyval", "readonly"); + const request = tx.objectStore("keyval").get(target); + tx.oncomplete = () => { + open.result.close(); + resolve(request.result); + }; + }; + open.onerror = () => resolve(undefined); + }), + key, + ) as Promise; +} + +export type Box = { w: number; h: number; x: number; y: number }; + +/** The card is the player root; the cell is the grid slot it sits in. */ +export type BoxTarget = "card" | "cell"; + +/** One atomic snapshot, or null while any tile is missing or unlaid out. */ +function snapshotBoxes( + page: Page, + cameras: readonly string[], + target: BoxTarget, +): Promise | null> { + return page.evaluate( + ({ cams, target }) => { + const boxes: Record = {}; + + for (const cam of cams) { + const card = document.querySelector(`[data-camera='${cam}']`); + const el = target === "cell" ? card?.closest(".p-1") : card; + + if (!el) { + return null; + } + + const r = el.getBoundingClientRect(); + + // a re-rendering tile can briefly report no box at all + if (!r.width || !r.height) { + return null; + } + + boxes[cam] = { w: r.width, h: r.height, x: r.x, y: r.y }; + } + + return boxes; + }, + { cams: cameras as readonly string[], target }, + ); +} + +/** + * Measure the given cameras' tiles together, once they have all rendered. + * Measuring in one evaluate is what keeps the numbers mutually comparable. + */ +export async function cameraBoxes( + page: Page, + cameras: readonly T[], + target: BoxTarget = "cell", +): Promise> { + let boxes: Record | null = null; + + await expect + .poll(async () => (boxes = await snapshotBoxes(page, cameras, target)), { + timeout: 10_000, + message: `${target}s never rendered for ${cameras.join(", ")}`, + }) + .not.toBeNull(); + + return boxes as unknown as Record; +} diff --git a/web/e2e/pages/live.page.ts b/web/e2e/pages/live.page.ts index 814064944b..4f9697f373 100644 --- a/web/e2e/pages/live.page.ts +++ b/web/e2e/pages/live.page.ts @@ -45,6 +45,11 @@ export class LivePage extends BasePage { ); } + /** Edit-layout toggle on the draggable grid (desktop, custom groups). */ + get editLayoutButton(): Locator { + return this.page.getByTestId("toggle-edit-layout"); + } + /** Open the right-click context menu on a camera card (desktop only). */ async openContextMenuOn(cameraName: string): Promise { await this.cameraCard(cameraName).first().click({ button: "right" }); diff --git a/web/e2e/specs/live-grid-aspect-modes.spec.ts b/web/e2e/specs/live-grid-aspect-modes.spec.ts new file mode 100644 index 0000000000..7ce92568ff --- /dev/null +++ b/web/e2e/specs/live-grid-aspect-modes.spec.ts @@ -0,0 +1,185 @@ +/** + * Live grid aspect modes. + * + * Bucketed mode (the default) snaps every camera to a wide, landscape or tall + * tile, and converts layouts saved by pre-masonry versions instead of + * discarding them. Natural mode sizes each tile to its own camera. + */ + +import { test, expect } from "../fixtures/frigate-test"; +import { LivePage } from "../pages/live.page"; +import { + cameraBoxes, + persistedLayoutKey, + readLayout, + seedLayout, + type LayoutItem, +} from "../helpers/grid-layout"; + +const GROUP = "outdoor"; +const GRID_COLS = 96; + +test.describe("Live grid aspect modes @critical", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Draggable grid is desktop-only", + ); + + test("an ultra-wide camera gets a 32:9 tile in bucketed mode @mobile", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ + config: { + cameras: { backyard: { detect: { width: 2560, height: 720 } } }, + }, + }); + await frigateApp.goto(`/?group=${GROUP}`); + const live = new LivePage(frigateApp.page, true); + await expect(live.cameraCard("backyard").first()).toBeVisible({ + timeout: 10_000, + }); + + const { backyard: wide, front_door: normal } = await cameraBoxes( + frigateApp.page, + ["backyard", "front_door"] as const, + ); + + expect(wide.w / wide.h).toBeCloseTo(32 / 9, 1); + expect(wide.w / normal.w).toBeCloseTo(2, 1); + expect(wide.h).toBeCloseTo(normal.h, 0); + }); + + test("a letterboxed still image rounds its own corners", async ({ + frigateApp, + }) => { + // A portrait camera pillarboxes inside its 8:9 bucket, so the card's + // overflow-hidden clip never reaches the picture's corners. The image has + // to carry the radius itself or it renders with square edges on the tile. + await frigateApp.installDefaults({ + config: { + cameras: { backyard: { detect: { width: 720, height: 1280 } } }, + }, + }); + await frigateApp.goto(`/?group=${GROUP}`); + const live = new LivePage(frigateApp.page, true); + await expect(live.cameraCard("backyard").first()).toBeVisible({ + timeout: 10_000, + }); + + const radii = await frigateApp.page.evaluate(() => { + const card = document.querySelector("[data-camera='backyard']"); + const img = card?.querySelector("img"); + return { + card: card ? getComputedStyle(card).borderTopLeftRadius : null, + img: img ? getComputedStyle(img).borderTopLeftRadius : null, + }; + }); + + expect(radii.card).not.toBe("0px"); + expect(radii.img).toBe(radii.card); + }); + + test("a pre-masonry layout is converted, keeping resized tiles", async ({ + frigateApp, + }) => { + await frigateApp.goto(`/?group=${GROUP}`); + const live = new LivePage(frigateApp.page, true); + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + + // 0.17/0.18 shape: bare array on a 12-column grid, 4x4 standard tiles. + // backyard was manually resized to 8x8 and sits beside front_door's column, + // front_door is a standard tile on the row below. + const key = await persistedLayoutKey(frigateApp.page, GROUP); + await seedLayout(frigateApp.page, key, [ + { i: "backyard", x: 4, y: 0, w: 8, h: 8, moved: false, static: false }, + { i: "front_door", x: 0, y: 8, w: 4, h: 4, moved: false, static: false }, + ]); + await frigateApp.page.reload(); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + + // The conversion is written back on first load, replacing the legacy array + // with an envelope. Poll for it: that write is an async idb put. + await expect + .poll(async () => (await readLayout(frigateApp.page, key))?.version, { + timeout: 10_000, + }) + .toBe(2); + + const stored = (await readLayout(frigateApp.page, key))!; + expect(stored).toMatchObject({ version: 2, naturalAspect: false }); + + // x and w scale 8x (12 -> 96 columns), y and h scale 18x (4 -> 72 rows per + // standard tile), so the manual resize survives instead of snapping back. + expect( + stored.layout.find((i: LayoutItem) => i.i === "backyard"), + ).toMatchObject({ + x: 32, + y: 0, + w: 64, + h: 144, + }); + expect( + stored.layout.find((i: LayoutItem) => i.i === "front_door"), + ).toMatchObject({ + x: 0, + y: 144, + w: 32, + h: 72, + }); + + // arrangement on screen: backyard indented, front_door below it + const { backyard, front_door: frontDoor } = await cameraBoxes( + frigateApp.page, + ["backyard", "front_door"] as const, + ); + expect(backyard.x).toBeGreaterThan(frontDoor.x + frontDoor.w / 2); + expect(frontDoor.y).toBeGreaterThan(backyard.y + backyard.h / 2); + }); + + test("conversion is a pure scale, so odd sizes and positions survive", async ({ + frigateApp, + }) => { + await frigateApp.goto(`/?group=${GROUP}`); + const live = new LivePage(frigateApp.page, true); + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + + // The old grid exposed all four resize corners with no aspect constraint, + // so a stored tile can be any size. These two are adjacent and non-standard. + const key = await persistedLayoutKey(frigateApp.page, GROUP); + await seedLayout(frigateApp.page, key, [ + { i: "front_door", x: 0, y: 3, w: 5, h: 5 }, + { i: "backyard", x: 5, y: 3, w: 7, h: 5 }, + ]); + await frigateApp.page.reload(); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + await expect(live.cameraCard("backyard").first()).toBeVisible({ + timeout: 10_000, + }); + + await expect + .poll(async () => (await readLayout(frigateApp.page, key))?.version, { + timeout: 10_000, + }) + .toBe(2); + + const stored = (await readLayout(frigateApp.page, key))!; + const frontDoor = stored.layout.find( + (i: LayoutItem) => i.i === "front_door", + )!; + const backyard = stored.layout.find((i: LayoutItem) => i.i === "backyard")!; + + expect(frontDoor).toMatchObject({ x: 0, y: 54, w: 40, h: 90 }); + expect(backyard).toMatchObject({ x: 40, y: 54, w: 56, h: 90 }); + + // still adjacent, still inside the grid, still not overlapping + expect(frontDoor.x + frontDoor.w).toBe(backyard.x); + expect(backyard.x + backyard.w).toBe(GRID_COLS); + }); +}); diff --git a/web/e2e/specs/masonry-live-grid.spec.ts b/web/e2e/specs/masonry-live-grid.spec.ts new file mode 100644 index 0000000000..c00ff10124 --- /dev/null +++ b/web/e2e/specs/masonry-live-grid.spec.ts @@ -0,0 +1,269 @@ +/** + * Masonry live grid -- custom-group draggable layout. + * + * Verifies natural-aspect tile sizing and that a saved layout the current + * version cannot read is regenerated cleanly. The grid renders only for a + * custom camera group (here: "outdoor") on desktop; mobile keeps the static + * grid, which the @mobile block below guards. + */ + +import { test, expect } from "../fixtures/frigate-test"; +import { LivePage } from "../pages/live.page"; +import { + cameraBoxes, + persistedLayoutKey, + readLayout, + seedLayout, +} from "../helpers/grid-layout"; + +const GROUP = "outdoor"; // custom group: front_door + backyard + +test.describe("Masonry live grid @critical", () => { + test.skip( + ({ frigateApp }) => frigateApp.isMobile, + "Draggable masonry grid is desktop-only", + ); + + test("custom group renders its cameras in the draggable grid", async ({ + frigateApp, + }) => { + await frigateApp.goto(`/?group=${GROUP}`); + const live = new LivePage(frigateApp.page, true); + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + await expect(live.cameraCard("backyard").first()).toBeVisible(); + }); + + test("tiles render at their camera's natural aspect ratio", async ({ + frigateApp, + }) => { + // front_door stays 16:9; backyard is overridden to portrait so the two + // tiles must render with opposite orientations. + await frigateApp.installDefaults({ + config: { + cameras: { backyard: { detect: { width: 720, height: 1280 } } }, + }, + }); + await frigateApp.goto(`/?group=${GROUP}`); + const live = new LivePage(frigateApp.page, true); + + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + + const { front_door: landscape, backyard: portrait } = await cameraBoxes( + frigateApp.page, + ["front_door", "backyard"] as const, + "card", + ); + + // 16:9 tile is clearly wider than tall; portrait tile is taller than wide. + expect(landscape.w / landscape.h).toBeGreaterThan(1.4); + expect(portrait.w / portrait.h).toBeLessThan(1); + }); + + test("dragging a tile does not shove other tiles far away", async ({ + frigateApp, + }) => { + await frigateApp.goto(`/?group=${GROUP}`); + const live = new LivePage(frigateApp.page, true); + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + + await live.editLayoutButton.click(); + + const cameras = ["front_door", "backyard"] as const; + const { front_door: fixedBefore, backyard: draggedBox } = await cameraBoxes( + frigateApp.page, + cameras, + "card", + ); + + // Drag backyard onto front_door's position (a deliberate collision). With + // free-placement + prevent-collision, front_door must NOT be shoved down. + const from = { + x: draggedBox.x + draggedBox.w / 2, + y: draggedBox.y + draggedBox.h / 2, + }; + const to = { + x: fixedBefore.x + fixedBefore.w / 2, + y: fixedBefore.y + fixedBefore.h / 2, + }; + await frigateApp.page.mouse.move(from.x, from.y); + await frigateApp.page.mouse.down(); + await frigateApp.page.mouse.move(to.x, to.y, { steps: 15 }); + await frigateApp.page.mouse.up(); + + const { front_door: fixedAfter } = await cameraBoxes( + frigateApp.page, + cameras, + "card", + ); + // Allow a few px of snap; a collision-push would move it a whole tile down. + expect(Math.abs(fixedAfter.y - fixedBefore.y)).toBeLessThan(40); + }); + + test("resizing a top-row tile preserves its aspect ratio (no pillarboxing)", async ({ + frigateApp, + }) => { + // A lone top tile has room to grow sideways, which is what exposed the bug: + // a top-edge handle let width grow while height stayed clamped at y=0. + await frigateApp.installDefaults({ + config: { camera_groups: { outdoor: { cameras: ["front_door"] } } }, + }); + await frigateApp.goto(`/?group=${GROUP}`); + const live = new LivePage(frigateApp.page, true); + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + await live.editLayoutButton.click(); + + const tile = frigateApp.page.locator(".react-grid-item", { + has: frigateApp.page.locator("[data-camera='front_door']"), + }); + const only = ["front_door"] as const; + const { front_door: before } = await cameraBoxes( + frigateApp.page, + only, + "card", + ); + const aspect = before.w / before.h; + + // Regression: if a top-edge handle is exposed, dragging it up/out must NOT + // distort the aspect (the old bug grew width while height stayed clamped). + const ne = tile.locator(".react-resizable-handle-ne"); + if (await ne.count()) { + await ne.dragTo(tile, { + force: true, + targetPosition: { x: 1000, y: -160 }, + }); + const { front_door: afterNe } = await cameraBoxes( + frigateApp.page, + only, + "card", + ); + // It must actually resize (not a silent no-op) AND keep its aspect. + expect(afterNe.w).toBeGreaterThan(before.w); + expect(Math.abs(afterNe.w / afterNe.h - aspect)).toBeLessThan(0.2); + } + + // Positive: growing from the bottom-right corner resizes and keeps aspect. + const se = tile.locator(".react-resizable-handle-se"); + await se.dragTo(tile, { force: true, targetPosition: { x: 1000, y: 520 } }); + const { front_door: grown } = await cameraBoxes( + frigateApp.page, + only, + "card", + ); + expect(grown.w).toBeGreaterThan(before.w); + expect(Math.abs(grown.w / grown.h - aspect)).toBeLessThan(0.2); + }); + + test("the grid keeps its measured width after a back navigation", async ({ + frigateApp, + }) => { + // The grid sizes itself from window.innerWidth until its container is + // measured. On a warm back navigation nothing re-renders after that + // container mounts, so an observer that never attaches leaves every tile + // sized against the full window: the layout widens by the sidebar's width + // and the rightmost column clips on a full row. + await frigateApp.goto(`/?group=${GROUP}`); + const live = new LivePage(frigateApp.page, true); + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + + // total width the tiles span; tracks the width the grid laid out against + const span = () => + frigateApp.page.evaluate(() => { + const tiles = [...document.querySelectorAll(".react-grid-item")]; + + if (!tiles.length) { + return null; + } + + const rects = tiles.map((tile) => tile.getBoundingClientRect()); + return +( + Math.max(...rects.map((r) => r.right)) - + Math.min(...rects.map((r) => r.left)) + ).toFixed(1); + }); + + let fresh: number | null = null; + await expect + .poll(async () => (fresh = await span()), { timeout: 10_000 }) + .not.toBeNull(); + + await live.cameraCard("front_door").first().click(); + await expect(frigateApp.page).toHaveURL(/#front_door/); + + await frigateApp.page.goBack(); + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + + // the layout must settle back to the measured width, not window.innerWidth + await expect + .poll(span, { timeout: 10_000 }) + .toBeLessThanOrEqual(fresh! + 2); + }); + + test("saved layout from an unreadable version regenerates without error", async ({ + frigateApp, + }) => { + await frigateApp.goto(`/?group=${GROUP}`); + const live = new LivePage(frigateApp.page, true); + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + + // A bare array is converted rather than discarded (covered in + // live-grid-aspect-modes), so use a version the current grid cannot read. + const key = await persistedLayoutKey(frigateApp.page, GROUP); + await seedLayout(frigateApp.page, key, { + version: 1, + naturalAspect: false, + layout: [{ i: "front_door", x: 0, y: 0, w: 4, h: 3 }], + }); + + await frigateApp.page.reload(); + await frigateApp.page.waitForSelector("#pageRoot", { timeout: 10_000 }); + + // Grid regenerated; both cameras still render and the error collector + // (frigate-test fixture) catches any crash. + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + await expect(live.cameraCard("backyard").first()).toBeVisible(); + + // The app must have replaced the value it could not read. Without this the + // test would still pass against a key the app never touches. + await expect + .poll(async () => (await readLayout(frigateApp.page, key))?.version, { + timeout: 10_000, + }) + .toBeGreaterThan(1); + }); +}); + +test.describe("Masonry live grid on mobile @critical @mobile", () => { + test("custom group keeps the static grid, with no draggable layout", async ({ + frigateApp, + }) => { + test.skip(!frigateApp.isMobile, "Mobile-only"); + + await frigateApp.goto(`/?group=${GROUP}`); + const live = new LivePage(frigateApp.page, false); + await expect(live.cameraCard("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + await expect(live.cameraCard("backyard").first()).toBeVisible(); + + // isMobileOnly routes around DraggableGridLayout entirely, so neither the + // grid items nor the edit-layout toggle may appear. + await expect(frigateApp.page.locator(".react-grid-item")).toHaveCount(0); + await expect(live.editLayoutButton).toHaveCount(0); + }); +}); diff --git a/web/e2e/specs/settings/ui-settings-transfer.spec.ts b/web/e2e/specs/settings/ui-settings-transfer.spec.ts index 71c553f496..ac61ea953e 100644 --- a/web/e2e/specs/settings/ui-settings-transfer.spec.ts +++ b/web/e2e/specs/settings/ui-settings-transfer.spec.ts @@ -13,7 +13,26 @@ import type { Page } from "@playwright/test"; const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin"; const STREAMING_KEY = "streaming-settings:admin"; -const OUTDOOR_LAYOUT = [ +// the shape DraggableGridLayout writes +const OUTDOOR_LAYOUT = { + version: 2, + naturalAspect: false, + layout: [ + { i: "front_door", x: 0, y: 0, w: 32, h: 72 }, + { i: "backyard", x: 32, y: 0, w: 32, h: 72 }, + ], +}; + +const NATURAL_OUTDOOR_LAYOUT = { + version: 2, + naturalAspect: true, + layout: [ + { i: "front_door", x: 0, y: 0, w: 32, h: 72 }, + { i: "backyard", x: 32, y: 0, w: 24, h: 96 }, + ], +}; + +const LEGACY_OUTDOOR_LAYOUT = [ { i: "front_door", x: 0, y: 0, w: 6, h: 4 }, { i: "backyard", x: 6, y: 0, w: 6, h: 4 }, ]; @@ -159,6 +178,111 @@ test.describe("UI settings import/export @medium", () => { expect(payload.sections.preferences.playbackRate).toBe(2); }); + test("round-trips a layout left unconverted by an upgrade", async ({ + frigateApp, + }) => { + // DraggableGridLayout rewrites a pre-0.19 layout only when that group's + // dashboard is opened, so exporting first carries the bare array into the + // file. Import must accept it back rather than rejecting the whole file. + await frigateApp.goto("/settings?page=uiSettings"); + + await writeIdb(frigateApp.page, { + [OUTDOOR_LAYOUT_KEY]: LEGACY_OUTDOOR_LAYOUT, + "playbackRate:admin": 2, + }); + + const downloadPromise = frigateApp.page.waitForEvent("download"); + await frigateApp.page + .getByRole("button", { name: "Export Settings" }) + .click(); + const download = await downloadPromise; + const contents = readFileSync((await download.path())!, "utf-8"); + + expect(JSON.parse(contents).sections.layouts.outdoor).toEqual( + LEGACY_OUTDOOR_LAYOUT, + ); + + await clearIdb(frigateApp.page); + await chooseImportText(frigateApp.page, contents); + await confirmImport(frigateApp.page); + + expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( + LEGACY_OUTDOOR_LAYOUT, + ); + // the rest of the file must survive alongside it + expect(await readIdb(frigateApp.page, "playbackRate:admin")).toBe(2); + }); + + test("legacy layouts import turns natural aspect off so they display", async ({ + frigateApp, + }) => { + // Bare-array layouts only render in bucketed mode; with natural aspect on + // they would be discarded and regenerated on the next dashboard visit. The + // import applies the mode the layouts were built for, and the file's own + // naturalAspectLayout preference must not override that. + await frigateApp.goto("/settings?page=uiSettings"); + await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true }); + + await chooseImportFile( + frigateApp.page, + importPayload({ + sections: { + layouts: { outdoor: LEGACY_OUTDOOR_LAYOUT }, + streaming: {}, + preferences: { naturalAspectLayout: true }, + }, + }), + ); + + const note = frigateApp.page.getByText(/standard tile sizing/); + await expect(note).toBeVisible(); + + // the note is about the layouts section, so it follows its switch + await frigateApp.page.getByText("Camera group layouts (1 group)").click(); + await expect(note).toBeHidden(); + await frigateApp.page.getByText("Camera group layouts (1 group)").click(); + await expect(note).toBeVisible(); + + await confirmImport(frigateApp.page); + + expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( + LEGACY_OUTDOOR_LAYOUT, + ); + expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe( + false, + ); + }); + + test("natural aspect layouts import turns the setting on", async ({ + frigateApp, + }) => { + await frigateApp.goto("/settings?page=uiSettings"); + + await chooseImportFile( + frigateApp.page, + importPayload({ + sections: { + layouts: { outdoor: NATURAL_OUTDOOR_LAYOUT }, + streaming: {}, + preferences: {}, + }, + }), + ); + + await expect( + frigateApp.page.getByText(/camera aspect ratio tile sizing/), + ).toBeVisible(); + + await confirmImport(frigateApp.page); + + expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( + NATURAL_OUTDOOR_LAYOUT, + ); + expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe( + true, + ); + }); + test("omits settings that were never stored", async ({ frigateApp }) => { await frigateApp.goto("/settings?page=uiSettings"); diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 1df503441f..21fa7adb31 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -167,6 +167,11 @@ "label": "Always Show Camera Names", "desc": "Always show the camera names in a chip in the multi-camera live view dashboard." }, + "naturalAspectLayout": { + "label": "Use Camera Aspect Ratios", + "desc": "On camera group live view dashboards, size each tile to its camera's own aspect ratio. When disabled, cameras are snapped to a standard wide, landscape, or tall tile shape.", + "descNote": "Toggling this setting on or off will clear the layout for all camera group live dashboards." + }, "liveFallbackTimeout": { "label": "Live Player Fallback Timeout", "desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3." @@ -195,6 +200,8 @@ "desc": "Choose what to apply from this file. Frigate will reload when the import finishes.", "exportedFrom": "Exported {{date}} from Frigate config version {{version}}", "layouts_one": "Camera group layouts ({{count}} group)", + "layoutsModeOn": "These layouts use camera aspect ratio tile sizing, so importing them will also turn on \"Use Camera Aspect Ratios\".", + "layoutsModeOff": "These layouts use standard tile sizing, so importing them will also turn off \"Use Camera Aspect Ratios\".", "layouts_other": "Camera group layouts ({{count}} groups)", "streaming_one": "Streaming settings ({{count}} camera)", "streaming_other": "Streaming settings ({{count}} cameras)", diff --git a/web/src/components/camera/CameraImage.tsx b/web/src/components/camera/CameraImage.tsx index f0c05995ed..0ba7ea19e7 100644 --- a/web/src/components/camera/CameraImage.tsx +++ b/web/src/components/camera/CameraImage.tsx @@ -103,7 +103,7 @@ export default function CameraImage({ )} {!imageLoaded && enabled ? (
- +
) : null} diff --git a/web/src/components/overlay/ImageShadowOverlay.tsx b/web/src/components/overlay/ImageShadowOverlay.tsx index 4f822572d2..f2410674e6 100644 --- a/web/src/components/overlay/ImageShadowOverlay.tsx +++ b/web/src/components/overlay/ImageShadowOverlay.tsx @@ -12,13 +12,13 @@ export function ImageShadowOverlay({ <>
diff --git a/web/src/components/overlay/dialog/ImportUiSettingsDialog.tsx b/web/src/components/overlay/dialog/ImportUiSettingsDialog.tsx index fa75b7d6eb..8d13195842 100644 --- a/web/src/components/overlay/dialog/ImportUiSettingsDialog.tsx +++ b/web/src/components/overlay/dialog/ImportUiSettingsDialog.tsx @@ -10,10 +10,11 @@ import { } from "@/components/ui/dialog"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { LuTriangleAlert } from "react-icons/lu"; +import { LuInfo, LuTriangleAlert } from "react-icons/lu"; import FilterSwitch from "@/components/filter/FilterSwitch"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { + importedLayoutsNaturalAspect, ImportSummary, TransferSection, UiSettingsFile, @@ -25,6 +26,7 @@ type ImportUiSettingsDialogProps = { fileName: string; file: UiSettingsFile; summary: ImportSummary; + currentNaturalAspect: boolean; onConfirm: (sections: Record) => Promise; }; @@ -34,6 +36,7 @@ export default function ImportUiSettingsDialog({ fileName, file, summary, + currentNaturalAspect, onConfirm, }: ImportUiSettingsDialogProps) { const { t } = useTranslation(["views/settings", "common"]); @@ -90,6 +93,16 @@ export default function ImportUiSettingsDialog({ [sections.streaming, summary.unknownCameras], ); + // importing layouts also applies the tile-sizing mode they were built for + const layoutsModeChange = useMemo(() => { + if (!sections.layouts) { + return null; + } + + const mode = importedLayoutsNaturalAspect(file); + return mode === null || mode === currentNaturalAspect ? null : mode; + }, [sections.layouts, file, currentNaturalAspect]); + const handleConfirm = useCallback(async () => { setIsImporting(true); await onConfirm(sections); @@ -156,6 +169,19 @@ export default function ImportUiSettingsDialog({ />
+ {layoutsModeChange !== null && ( + + + + {t( + layoutsModeChange + ? "general.backupRestore.importDialog.layoutsModeOn" + : "general.backupRestore.importDialog.layoutsModeOff", + )} + + + )} + {(visibleUnknownGroups.length > 0 || visibleUnknownCameras.length > 0) && ( diff --git a/web/src/components/player/BirdseyeLivePlayer.tsx b/web/src/components/player/BirdseyeLivePlayer.tsx index 3dcd6afe7c..5fd42e18cd 100644 --- a/web/src/components/player/BirdseyeLivePlayer.tsx +++ b/web/src/components/player/BirdseyeLivePlayer.tsx @@ -29,22 +29,10 @@ export default function BirdseyeLivePlayer({ }: LivePlayerProps) { let player; if (liveMode == "webrtc") { - player = ( - - ); + player = ; } else if (liveMode == "mse") { if ("MediaSource" in window || "ManagedMediaSource" in window) { - player = ( - - ); + player = ; } else { player = (
@@ -55,7 +43,7 @@ export default function BirdseyeLivePlayer({ } else if (liveMode == "jsmpeg") { player = ( ); } else { - player = ; + player = ; } return (
- +
+
+ +
+
{player}
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 69da66e016..1570cbe69a 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -54,6 +54,10 @@ type LivePlayerProps = { onError?: (error: LivePlayerError) => void; onMicrophoneError?: (error: TwoWayTalkError) => void; onResetLiveMode?: () => void; + // Aspect of the source currently on screen: the live stream's while it is + // playing, undefined while the still image is showing. The still comes from + // the detect stream, which can be shaped differently than the live one. + onLiveAspectChange?: (aspectRatio: number | undefined) => void; }; export default function LivePlayer({ @@ -80,6 +84,7 @@ export default function LivePlayer({ onError, onMicrophoneError, onResetLiveMode, + onLiveAspectChange, }: LivePlayerProps) { const { t } = useTranslation(["components/player"]); @@ -127,6 +132,37 @@ export default function LivePlayer({ // camera live state const [liveReady, setLiveReady] = useState(false); + const [liveAspect, setLiveAspect] = useState(); + + const handleFullResolution = useCallback( + (value: React.SetStateAction) => { + setFullResolution?.(value); + + if (typeof value === "function") { + return; + } + + setLiveAspect( + value.width && value.height ? value.width / value.height : undefined, + ); + }, + [setFullResolution], + ); + + useEffect(() => { + onLiveAspectChange?.(liveReady ? liveAspect : undefined); + }, [liveReady, liveAspect, onLiveAspectChange]); + + // The card can be a different shape than the picture (a bucketed tile, or a + // still whose detect aspect differs from the stream), so overlays that are + // meant to sit on the image have to be fitted to it rather than to the card. + const pictureAspect = useMemo(() => { + if (liveReady && liveAspect) { + return liveAspect; + } + const { width, height } = cameraConfig.detect; + return width && height ? width / height : 16 / 9; + }, [liveReady, liveAspect, cameraConfig.detect]); const liveReadyRef = useRef(liveReady); const cameraActiveRef = useRef(cameraActive); @@ -262,11 +298,12 @@ export default function LivePlayer({ player = ( ); @@ -308,7 +345,7 @@ export default function LivePlayer({ player = ( ; + player = ( + + ); } return ( @@ -340,10 +379,12 @@ export default function LivePlayer({ }} data-camera={cameraConfig.name} className={cn( - "relative flex w-full cursor-pointer justify-center outline", + // the card owns the corner: overflow-hidden clips the stream, the still + // image, and every overlay to this one radius so they stay concentric + "relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg outline md:rounded-2xl", activeTracking && ((showStillWithoutActivity && !liveReady) || liveReady) - ? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl" + ? "shadow-severity_alert outline-[3px] outline-severity_alert" : "outline-0 outline-background", "transition-all duration-500", className, @@ -357,18 +398,24 @@ export default function LivePlayer({ > {cameraEnabled && ((showStillWithoutActivity && !liveReady) || liveReady) && ( - +
+
+ +
+
)} {player} {cameraEnabled && !offline && (!showStillWithoutActivity || isReEnabling) && !liveReady && ( + (
- + + )
)} @@ -447,7 +494,7 @@ export default function LivePlayer({ {offline && inDashboard && ( <> -
+
{t("streamOffline.title")}
@@ -491,7 +538,7 @@ export default function LivePlayer({ )} {!cameraEnabled && ( -
+

diff --git a/web/src/components/player/WebRTCPlayer.tsx b/web/src/components/player/WebRTCPlayer.tsx index cbdab7331a..ca8adc10ae 100644 --- a/web/src/components/player/WebRTCPlayer.tsx +++ b/web/src/components/player/WebRTCPlayer.tsx @@ -3,6 +3,7 @@ import { LivePlayerError, PlayerStatsType, TwoWayTalkError, + VideoResolutionType, } from "@/types/live"; import { FrigateConfig } from "@/types/frigateConfig"; import { webRTCIceServers } from "@/utils/webrtcUtil"; @@ -20,6 +21,7 @@ type WebRtcPlayerProps = { pip?: boolean; getStats?: boolean; setStats?: (stats: PlayerStatsType) => void; + setFullResolution?: React.Dispatch>; onPlaying?: () => void; onError?: (error: LivePlayerError) => void; onMicrophoneError?: (error: TwoWayTalkError) => void; @@ -36,6 +38,7 @@ export default function WebRtcPlayer({ pip = false, getStats = false, setStats, + setFullResolution, onPlaying, onError, onMicrophoneError, @@ -342,6 +345,12 @@ export default function WebRtcPlayer({ if (videoLoadTimeoutRef.current) { clearTimeout(videoLoadTimeoutRef.current); } + if (videoRef.current) { + setFullResolution?.({ + width: videoRef.current.videoWidth, + height: videoRef.current.videoHeight, + }); + } onPlaying?.(); }; diff --git a/web/src/index.css b/web/src/index.css index 157b8605d9..4b58645d8c 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -175,6 +175,21 @@ html { background-image: none !important; } +/* Live masonry grid: only the bottom-right corner resizes, drawn as a corner + bracket on the real se handle (drop-shadow keeps it legible over footage). */ +.grid-layout .react-resizable-handle-se::after { + content: ""; + position: absolute; + right: 5px; + bottom: 5px; + width: 12px; + height: 12px; + border-right: 2.5px solid rgba(233, 238, 246, 0.92); + border-bottom: 2.5px solid rgba(233, 238, 246, 0.92); + border-bottom-right-radius: 3px; + filter: drop-shadow(0 0 1.5px rgba(0, 0, 0, 0.9)); +} + .react-grid-item.react-grid-placeholder { border: 3px solid #a00000 !important; opacity: 0.5 !important; diff --git a/web/src/utils/uiSettingsTransfer.ts b/web/src/utils/uiSettingsTransfer.ts index da11ad5c19..e9e7a6b01e 100644 --- a/web/src/utils/uiSettingsTransfer.ts +++ b/web/src/utils/uiSettingsTransfer.ts @@ -54,6 +54,12 @@ export const TRANSFER_KEYS: TransferKey[] = [ namespaced: true, schema: z.boolean(), }, + { + key: "naturalAspectLayout", + section: "preferences", + namespaced: true, + schema: z.boolean(), + }, { key: "alertVideos", section: "preferences", @@ -182,13 +188,22 @@ const layoutItemSchema = z }) .passthrough(); +// a group whose dashboard has not been opened since upgrading still holds +// the pre-0.19 bare array, so both shapes reach the file +const storedLayoutSchema = z.union([ + z.array(layoutItemSchema), + z + .object({ version: z.number(), layout: z.array(layoutItemSchema) }) + .passthrough(), +]); + export const uiSettingsFileSchema = z.object({ type: z.literal(UI_SETTINGS_FILE_TYPE), version: z.number().int().positive(), exported_at: z.string(), frigate_version: z.string(), sections: z.object({ - layouts: z.record(z.string(), z.array(layoutItemSchema)), + layouts: z.record(z.string(), storedLayoutSchema), streaming: allGroupsStreamingSettingsSchema, preferences: z.record(z.string(), z.unknown()), }), @@ -384,6 +399,22 @@ export function summarizeImport( }; } +// Bare arrays are pre-masonry bucketed layouts. A layout only renders under +// the mode that built it, so importing layouts applies this mode too. +export function importedLayoutsNaturalAspect( + file: UiSettingsFile, +): boolean | null { + const layouts = Object.values(file.sections.layouts); + + if (!layouts.length) { + return null; + } + + return layouts.some( + (layout) => !Array.isArray(layout) && layout.naturalAspect === true, + ); +} + export function hasImportableContent(summary: ImportSummary): boolean { return ( summary.layoutGroupCount > 0 || @@ -398,6 +429,9 @@ export async function applyImportPayload( username: string | undefined, ): Promise { const writes: Promise[] = []; + const layoutsMode = sections.layouts + ? importedLayoutsNaturalAspect(file) + : null; if (sections.layouts) { Object.entries(file.sections.layouts).forEach(([group, layout]) => { @@ -408,6 +442,15 @@ export async function applyImportPayload( ), ); }); + + if (layoutsMode !== null) { + writes.push( + setData( + getUserNamespacedKey("naturalAspectLayout", username), + layoutsMode, + ), + ); + } } const streamingEntry = TRANSFER_KEYS.find( @@ -447,6 +490,12 @@ export async function applyImportPayload( if (sections.preferences) { validPreferenceEntries(file.sections.preferences).forEach( ({ entry, value }) => { + // the layouts must win this key or they import into a mode that + // cannot display them + if (entry.key === "naturalAspectLayout" && layoutsMode !== null) { + return; + } + writes.push(setData(storageKey(entry, username), value)); }, ); diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index c007b509bd..2d07959e3c 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -8,16 +8,17 @@ import { import React, { useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, } from "react"; +import { useResizeObserver } from "@/hooks/resize-observer"; import { Layout, LayoutItem, ResponsiveGridLayout as Responsive, } from "react-grid-layout"; +import { aspectRatio, getCompactor } from "react-grid-layout/core"; import "react-grid-layout/css/styles.css"; import "react-resizable/css/styles.css"; import { @@ -28,9 +29,8 @@ import { StatsState, VolumeState, } from "@/types/live"; -import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; +import { ASPECT_WIDE_LAYOUT } from "@/types/record"; import { Skeleton } from "@/components/ui/skeleton"; -import { useResizeObserver } from "@/hooks/resize-observer"; import { isEqual } from "lodash"; import useSWR from "swr"; import { isDesktop, isMobile } from "react-device-detect"; @@ -52,6 +52,43 @@ import LiveContextMenu from "@/components/menu/LiveContextMenu"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useTranslation } from "react-i18next"; +// rowHeight is 1/VERTICAL_RESOLUTION of a column, so h = round(w * +// VERTICAL_RESOLUTION / aspect) lands a tile on its camera's aspect. GRID_COLS +// also sets resize granularity: the aspect constraint derives height from +// width, making one column the smallest step in both axes. +const GRID_COLS = 96; +const TILE_BASE_W = 32; +const TILE_WIDE_W = 64; +const VERTICAL_RESOLUTION = 4; +const DEFAULT_ASPECT = 16 / 9; +// Bucketed tile shapes, matching the aspect-wide / aspect-tall Tailwind utilities. +const TILE_ASPECT_WIDE = 32 / 9; +const TILE_ASPECT_TALL = 8 / 9; + +// Cells quantize to whole rows/columns, so the card takes the camera's exact +// ratio and fits itself inside its cell. --ar and container-type live on the +// cell; min() picks whichever axis binds first. +const CARD_FIT = + "h-auto w-[min(100%,calc(100cqh*var(--ar)))] aspect-[var(--ar)]"; + +// Stored coordinates are grid units, so bump this whenever GRID_COLS or +// VERTICAL_RESOLUTION changes in a released version. +const LAYOUT_VERSION = 2; +type PersistedLayout = { + version: number; + naturalAspect: boolean; + layout: Layout; +}; + +// Without preventCollision, RGL shoves collided tiles down the page and never +// compacts them back. +const FREE_PLACEMENT_COMPACTOR = getCompactor(null, false, true); + +// 0.17/0.18 stored a bare array on a 12-column grid whose standard tile was +// 4x4. Bucketed mode reproduces that geometry, so those layouts convert exactly. +const LEGACY_GRID_COLS = 12; +const LEGACY_TILE_ROWS = 4; + type DraggableGridLayoutProps = { cameras: CameraConfig[]; cameraGroup: string; @@ -98,6 +135,64 @@ export default function DraggableGridLayout({ const { data: config } = useSWR("config"); const birdseyeConfig = useMemo(() => config?.birdseye, [config]); + const aspectRatios = useMemo(() => { + const map: { [key: string]: number } = {}; + if (birdseyeConfig) { + map["birdseye"] = + (birdseyeConfig.width || 1) / (birdseyeConfig.height || 1); + } + cameras.forEach((camera) => { + map[camera.name] = + camera.detect.width / camera.detect.height || DEFAULT_ASPECT; + }); + return map; + }, [cameras, birdseyeConfig]); + + const [naturalAspectSetting, , isNaturalAspectLoaded] = useUserPersistence( + "naturalAspectLayout", + false, + ); + const naturalAspectLayout = naturalAspectSetting ?? false; + + // Bucketed mode snaps every camera to one of three tile shapes, matching the + // pre-masonry layout; the picture letterboxes inside its bucket. + const layoutAspects = useMemo(() => { + if (naturalAspectLayout) { + return aspectRatios; + } + const map: { [key: string]: number } = {}; + Object.entries(aspectRatios).forEach(([name, ratio]) => { + map[name] = + ratio > ASPECT_WIDE_LAYOUT + ? TILE_ASPECT_WIDE + : ratio < 1 + ? TILE_ASPECT_TALL + : DEFAULT_ASPECT; + }); + return map; + }, [aspectRatios, naturalAspectLayout]); + + // A live stream can be shaped differently than detect, so the card follows + // whatever is on screen and falls back to detect. Cells stay detect-sized, so + // only the card resizes. + const [liveAspects, setLiveAspects] = useState<{ + [key: string]: number | undefined; + }>({}); + + const liveAspectHandlers = useMemo(() => { + const map: { [key: string]: (aspectRatio: number | undefined) => void } = + {}; + cameras.forEach((camera) => { + map[camera.name] = (aspectRatio) => + setLiveAspects((prev) => + prev[camera.name] === aspectRatio + ? prev + : { ...prev, [camera.name]: aspectRatio }, + ); + }); + return map; + }, [cameras]); + // preferred live modes per camera const [globalAutoLive] = useUserPersistence("autoLiveView", true); @@ -115,7 +210,31 @@ export default function DraggableGridLayout({ // grid layout const [gridLayout, setGridLayout, isGridLayoutLoaded] = - useUserPersistence(`${cameraGroup}-draggable-layout`); + useUserPersistence(`${cameraGroup}-draggable-layout`); + + const readPersistedLayout = useCallback( + (stored: PersistedLayout | undefined): Layout | undefined => { + if ( + !stored || + stored.version !== LAYOUT_VERSION || + !Array.isArray(stored.layout) + ) { + return undefined; + } + return stored.layout; + }, + [], + ); + + // Strips per-item `constraints`, which are functions. + const toPersisted = useCallback( + (layout: Layout): PersistedLayout => ({ + version: LAYOUT_VERSION, + naturalAspect: naturalAspectLayout, + layout: layout.map(({ i, x, y, w, h }) => ({ i, x, y, w, h })), + }), + [naturalAspectLayout], + ); const [group] = useUserPersistedOverlayState( "cameraGroup", @@ -140,11 +259,11 @@ export default function DraggableGridLayout({ useEffect(() => { setIsEditMode(false); setEditGroup(false); - // Reset camera tracking state when group changes to prevent the camera-change - // effect from incorrectly overwriting the loaded layout + // Keeps the camera-change effect from overwriting the layout we load next. setCurrentCameras(undefined); setCurrentIncludeBirdseye(undefined); setCurrentGridLayout(undefined); + setCurrentNaturalAspect(undefined); }, [cameraGroup, setIsEditMode]); // camera state @@ -155,21 +274,84 @@ export default function DraggableGridLayout({ const [currentGridLayout, setCurrentGridLayout] = useState< Layout | undefined >(); + const [currentNaturalAspect, setCurrentNaturalAspect] = useState(); const handleLayoutChange = useCallback( (currentLayout: Layout) => { - if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) { + if ( + !isGridLayoutLoaded || + !isEqual(readPersistedLayout(gridLayout), currentGridLayout) + ) { return; } - // save layout to idb - setGridLayout(currentLayout); + setGridLayout(toPersisted(currentLayout)); setShowCircles(true); }, - [setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout], + [ + setGridLayout, + isGridLayoutLoaded, + gridLayout, + currentGridLayout, + readPersistedLayout, + toPersisted, + ], + ); + + const dimsFor = useCallback( + (name: string) => { + const ratio = layoutAspects[name] ?? DEFAULT_ASPECT; + const w = ratio >= ASPECT_WIDE_LAYOUT ? TILE_WIDE_W : TILE_BASE_W; + const h = Math.max(1, Math.round((w * VERTICAL_RESOLUTION) / ratio)); + return { w, h }; + }, + [layoutAspects], + ); + + // Rescale a pre-masonry layout onto the current grid. Both axes scale by a + // constant, so tiles the user resized keep their size and their arrangement + // stays intact. Only meaningful in bucketed mode, where a tile still has the + // shape those coordinates assumed. + const convertLegacyLayout = useCallback( + (stored: unknown): Layout | undefined => { + if (naturalAspectLayout || !Array.isArray(stored) || !stored.length) { + return undefined; + } + + const xScale = GRID_COLS / LEGACY_GRID_COLS; + const yScale = + Math.round((TILE_BASE_W * VERTICAL_RESOLUTION) / DEFAULT_ASPECT) / + LEGACY_TILE_ROWS; + const converted: LayoutItem[] = []; + + for (const item of stored) { + if ( + !item || + typeof item.i !== "string" || + typeof item.x !== "number" || + typeof item.y !== "number" || + typeof item.w !== "number" || + typeof item.h !== "number" + ) { + return undefined; + } + + const w = Math.min(Math.max(1, Math.round(item.w * xScale)), GRID_COLS); + converted.push({ + i: item.i, + x: Math.min(Math.max(0, Math.round(item.x * xScale)), GRID_COLS - w), + y: Math.max(0, Math.round(item.y * yScale)), + w, + h: Math.max(1, Math.round(item.h * yScale)), + }); + } + + return converted; + }, + [naturalAspectLayout], ); const generateLayout = useCallback( - (baseLayout: Layout | undefined) => { + (baseLayout: Layout | undefined): Layout | undefined => { if (!isGridLayoutLoaded) { return; } @@ -179,91 +361,92 @@ export default function DraggableGridLayout({ ? ["birdseye", ...cameras.map((camera) => camera?.name || "")] : cameras.map((camera) => camera?.name || ""); - const optionsMap: LayoutItem[] = baseLayout - ? baseLayout.filter((layout) => cameraNames?.includes(layout.i)) + const existing: LayoutItem[] = baseLayout + ? baseLayout.filter((layout) => cameraNames.includes(layout.i)) : []; + const placed = new Set(existing.map((layout) => layout.i)); - cameraNames.forEach((cameraName, index) => { - const existingLayout = optionsMap.find( - (layout) => layout.i === cameraName, - ); + const tileColumns = GRID_COLS / TILE_BASE_W; // 3 standard columns + // Start below existing items so new cameras never overlap the user's. + const maxBottom = existing.reduce( + (max, layout) => Math.max(max, layout.y + layout.h), + 0, + ); + const colBottoms = new Array(tileColumns).fill(maxBottom); - // Skip if the camera already exists in the layout - if (existingLayout) { + const result: LayoutItem[] = [...existing]; + + cameraNames.forEach((name) => { + if (placed.has(name)) { return; } + const { w, h } = dimsFor(name); - 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 + if (w === TILE_BASE_W) { + let col = 0; + for (let c = 1; c < tileColumns; c++) { + if (colBottoms[c] < colBottoms[col]) { + col = c; + } + } + result.push({ + i: name, + x: col * TILE_BASE_W, + y: colBottoms[col], + w, + h, + }); + colBottoms[col] += h; } else { - const camera = cameras.find((cam) => cam.name === cameraName); - aspectRatio = - (camera && camera?.detect.width / camera?.detect.height) || 16 / 9; - col = index % 3; // Regular cameras distributed across columns + 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; } - - // 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; + return result; }, - [cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig], + [cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig, dimsFor], ); 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); + if (!isGridLayoutLoaded) { + return; + } + + const saved = readPersistedLayout(gridLayout); + const converted = saved ? undefined : convertLegacyLayout(gridLayout); + const base = saved ?? converted; + + if (base) { + const updatedLayout = generateLayout(base) ?? base; + setCurrentGridLayout(updatedLayout); + if (converted || !isEqual(updatedLayout, base)) { + setGridLayout(toPersisted(updatedLayout)); } + setCurrentCameras(cameras); + setCurrentIncludeBirdseye(includeBirdseye); + setCurrentNaturalAspect( + converted ? naturalAspectLayout : gridLayout?.naturalAspect, + ); + } else { + // empty or incompatible (pre-masonry) data + const newLayout = generateLayout(undefined) ?? []; + setCurrentGridLayout(newLayout); + setGridLayout(toPersisted(newLayout)); + setCurrentCameras(cameras); + setCurrentIncludeBirdseye(includeBirdseye); + setCurrentNaturalAspect(naturalAspectLayout); } }, [ gridLayout, @@ -272,12 +455,15 @@ export default function DraggableGridLayout({ generateLayout, cameras, includeBirdseye, + naturalAspectLayout, + readPersistedLayout, + convertLegacyLayout, + toPersisted, ]); 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) + // Only for camera changes within a loaded group; undefined currentCameras + // means the load effect above has not run yet. if (!isGridLayoutLoaded || currentCameras === undefined) { return; } @@ -289,10 +475,10 @@ export default function DraggableGridLayout({ setCurrentCameras(cameras); setCurrentIncludeBirdseye(includeBirdseye); - // Regenerate layout based on current layout, adding any new cameras - const updatedLayout = generateLayout(currentGridLayout); + const updatedLayout = + generateLayout(currentGridLayout) ?? currentGridLayout ?? []; setCurrentGridLayout(updatedLayout); - setGridLayout(updatedLayout); + setGridLayout(toPersisted(updatedLayout)); } }, [ cameras, @@ -303,39 +489,47 @@ export default function DraggableGridLayout({ generateLayout, setGridLayout, isGridLayoutLoaded, + toPersisted, ]); - const [marginValue, setMarginValue] = useState(16); + useEffect(() => { + if ( + !isNaturalAspectLoaded || + currentNaturalAspect === undefined || + currentNaturalAspect === naturalAspectLayout + ) { + return; + } - // calculate margin value for browsers that don't have default font size of 16px - useLayoutEffect(() => { - const calculateRemValue = () => { - const htmlElement = document.documentElement; - const fontSize = window.getComputedStyle(htmlElement).fontSize; - setMarginValue(parseFloat(fontSize)); - }; + setCurrentNaturalAspect(naturalAspectLayout); + const regenerated = generateLayout(undefined) ?? []; + setCurrentGridLayout(regenerated); + setGridLayout(toPersisted(regenerated)); + }, [ + naturalAspectLayout, + isNaturalAspectLoaded, + currentNaturalAspect, + generateLayout, + setGridLayout, + toPersisted, + ]); - calculateRemValue(); + const gridContainerRef = useRef(null); + + // Commit-time measure: paints the first frame at the real width (no + // innerWidth flash), and the setState re-render is what lets + // useResizeObserver see a node mounted after the skeleton swap. + const [mountWidth, setMountWidth] = useState(null); + + const attachGridContainer = useCallback((node: HTMLDivElement | null) => { + gridContainerRef.current = node; + setMountWidth(node ? node.getBoundingClientRect().width : null); }, []); - const gridContainerRef = useRef(null); - const [{ width: containerWidth, height: containerHeight }] = useResizeObserver(gridContainerRef); - 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 availableWidth = containerWidth || mountWidth || 0; const hasScrollbar = useMemo(() => { if (containerHeight && containerRef.current) { @@ -346,61 +540,10 @@ export default function DraggableGridLayout({ }, [containerRef, containerHeight]); const cellHeight = useMemo(() => { - const aspectRatio = 16 / 9; - // subtract container margin, 1 camera takes up at least 4 rows - // account for additional margin on bottom of each row - return ( - ((availableWidth ?? window.innerWidth) - 2 * marginValue) / - 12 / - aspectRatio - - marginValue + - marginValue / 4 - ); - }, [availableWidth, marginValue]); - - 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; - }; + const width = availableWidth || window.innerWidth; + const columnWidth = width / GRID_COLS; + return columnWidth / VERTICAL_RESOLUTION; + }, [availableWidth]); // audio and stats states @@ -503,6 +646,19 @@ export default function DraggableGridLayout({ onSaveMuting(true); }; + // RGL's per-item constraint derives height from width, holding each tile at + // its camera's aspect while resizing. Constraints are functions, so they live + // only on this render copy; toPersisted strips them. + const layoutWithConstraints = useMemo(() => { + if (!currentGridLayout) { + return [] as Layout; + } + return currentGridLayout.map((item) => ({ + ...item, + constraints: [aspectRatio(layoutAspects[item.i] ?? DEFAULT_ASPECT)], + })); + }, [currentGridLayout, layoutAspects]); + return ( <> @@ -525,8 +681,8 @@ export default function DraggableGridLayout({

) : (
setShowCircles(false)} onResizeStop={handleLayoutChange} > @@ -570,22 +734,12 @@ export default function DraggableGridLayout({ "outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing", )} birdseyeConfig={birdseyeConfig} + aspectRatio={layoutAspects["birdseye"] ?? DEFAULT_ASPECT} liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"} onClick={() => onSelectCamera("birdseye")} - > - {isEditMode && showCircles && } - + > )} {cameras.map((camera) => { - let grow; - const aspectRatio = camera.detect.width / camera.detect.height; - if (aspectRatio > ASPECT_WIDE_LAYOUT) { - grow = `aspect-wide w-full`; - } else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) { - grow = `aspect-tall h-full`; - } else { - grow = "aspect-video"; - } const availableStreams = camera.live.streams || {}; const firstStreamEntry = Object.values(availableStreams)[0] || ""; @@ -614,7 +768,14 @@ export default function DraggableGridLayout({ ?.compatibilityMode || false; return ( resetPreferredLiveMode(camera.name)} playAudio={audioStates[camera.name]} volume={volumeStates[camera.name]} + onLiveAspectChange={liveAspectHandlers[camera.name]} /> - {isEditMode && showCircles && } ); })} @@ -694,6 +855,7 @@ export default function DraggableGridLayout({
setIsEditMode((prevIsEditMode) => !prevIsEditMode) @@ -762,17 +924,6 @@ export default function DraggableGridLayout({ ); } -function CornerCircles() { - return ( - <> -
-
-
-
- - ); -} - type BirdseyeLivePlayerGridItemProps = { style?: React.CSSProperties; className?: string; @@ -781,6 +932,7 @@ type BirdseyeLivePlayerGridItemProps = { onTouchEnd?: React.TouchEventHandler; children?: React.ReactNode; birdseyeConfig: BirdseyeConfig; + aspectRatio: number; liveMode: LivePlayerMode; onClick: () => void; }; @@ -798,6 +950,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef< onTouchEnd, children, birdseyeConfig, + aspectRatio: cellAspect, liveMode, onClick, ...props @@ -806,7 +959,8 @@ const BirdseyeLivePlayerGridItem = React.forwardRef< ) => { return (
; onMouseUp?: React.MouseEventHandler; onTouchEnd?: React.TouchEventHandler; @@ -860,6 +1015,7 @@ const GridLiveContextMenu = React.forwardRef< { className, style, + aspectRatio: cameraAspect, onMouseDown, onMouseUp, onTouchEnd, @@ -887,7 +1043,8 @@ const GridLiveContextMenu = React.forwardRef< ) => { return (
{isMobile && ( @@ -609,7 +609,7 @@ export default function LiveDashboardView({ void; }; @@ -63,6 +66,7 @@ function SwitchSettingRow({ id, label, description, + note, checked, onCheckedChange, }: SwitchSettingRowProps) { @@ -82,6 +86,15 @@ function SwitchSettingRow({

{description}

+ {note && ( + + + {note} + + )}
)}