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..76c3f6246a --- /dev/null +++ b/web/e2e/specs/masonry-live-grid.spec.ts @@ -0,0 +1,298 @@ +/** + * 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, + }) => { + // backyard is 9:16, which bucketed mode would snap to an 8:9 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("front_door").first()).toBeVisible({ + timeout: 10_000, + }); + + await seedLayout(frigateApp.page, "naturalAspectLayout:admin", true); + await frigateApp.page.reload(); + await expect(live.cameraCard("backyard").first()).toBeVisible({ + timeout: 10_000, + }); + + const { front_door: landscape, backyard: portrait } = await cameraBoxes( + frigateApp.page, + ["front_door", "backyard"] as const, + "card", + ); + + expect(landscape.w / landscape.h).toBeCloseTo(16 / 9, 1); + expect(portrait.w / portrait.h).toBeCloseTo(9 / 16, 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("a camera added to a saved layout fills an open column", 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, + }); + + // one tall tile in the first column; backyard is missing from the layout + const key = await persistedLayoutKey(frigateApp.page, GROUP); + await seedLayout(frigateApp.page, key, { + version: 2, + naturalAspect: false, + layout: [{ i: "front_door", x: 0, y: 0, w: 32, h: 400 }], + }); + await frigateApp.page.reload(); + + await expect + .poll(async () => { + const stored = await readLayout(frigateApp.page, key); + return stored?.layout.find((item) => item.i === "backyard"); + }) + .toMatchObject({ x: 32, y: 0 }); + }); + + 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..2716482a3c 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,160 @@ test.describe("UI settings import/export @medium", () => { expect(payload.sections.preferences.playbackRate).toBe(2); }); + test("exports only layouts built for the current tile sizing mode", async ({ + frigateApp, + }) => { + // a group not opened since the mode changed still holds a layout from + // the other mode, which would import into a mode that cannot show it + await frigateApp.goto("/settings?page=uiSettings"); + + await writeIdb(frigateApp.page, { + [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT, + "default-draggable-layout:admin": NATURAL_OUTDOOR_LAYOUT, + "naturalAspectLayout:admin": true, + }); + + const downloadPromise = frigateApp.page.waitForEvent("download"); + await frigateApp.page + .getByRole("button", { name: "Export Settings" }) + .click(); + const download = await downloadPromise; + const payload = JSON.parse(readFileSync((await download.path())!, "utf-8")); + + expect(payload.sections.layouts).toEqual({ + default: NATURAL_OUTDOOR_LAYOUT, + }); + }); + + test("toggling tile sizing mode clears stored layouts", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "The setting is hidden on phones"); + await frigateApp.goto("/settings?page=uiSettings"); + await writeIdb(frigateApp.page, { [OUTDOOR_LAYOUT_KEY]: OUTDOOR_LAYOUT }); + + await frigateApp.page.locator("#natural-aspect-desktop").click(); + await frigateApp.page + .getByRole("alertdialog") + .getByRole("button", { name: "Enable" }) + .click(); + + await expect + .poll(() => readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)) + .toBeNull(); + expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe( + true, + ); + }); + + test("round-trips a layout left unconverted by an upgrade", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only"); + // 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, + }) => { + test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only"); + // 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, + }) => { + test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only"); + 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"); @@ -190,12 +363,15 @@ test.describe("UI settings import/export @medium", () => { await expect( frigateApp.page.getByText("UI preferences (2 settings)"), ).toBeVisible(); - await expect(frigateApp.page.getByText(/patio/)).toBeVisible(); + // patio is layout-only, so its warning follows the layouts section + await expect(frigateApp.page.getByText(/patio/)).toBeVisible({ + visible: !frigateApp.isMobile, + }); await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( - OUTDOOR_LAYOUT, + frigateApp.isMobile ? null : OUTDOOR_LAYOUT, ); expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( STREAMING_SETTINGS, @@ -206,6 +382,7 @@ test.describe("UI settings import/export @medium", () => { test("hides the unknown-group warning when layouts are switched off", async ({ frigateApp, }) => { + test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only"); await frigateApp.goto("/settings?page=uiSettings"); // patio is a layout-only group absent from this server, so the warning @@ -235,7 +412,39 @@ test.describe("UI settings import/export @medium", () => { expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({}); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( - OUTDOOR_LAYOUT, + frigateApp.isMobile ? null : OUTDOOR_LAYOUT, + ); + }); + + test("phones refuse layouts and say why @mobile", async ({ frigateApp }) => { + test.skip(!frigateApp.isMobile, "Phone-only"); + + await frigateApp.goto("/settings?page=uiSettings"); + await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": false }); + + await chooseImportFile(frigateApp.page, importPayload()); + + await expect( + frigateApp.page.getByText(/aren't imported on phones/), + ).toBeVisible(); + + // the section is still listed, but cannot be switched on + await expect( + frigateApp.page.getByText("Camera group layouts (2 groups)"), + ).toBeVisible(); + await expect( + frigateApp.page.locator('[id="Camera group layouts (2 groups)"]'), + ).toBeDisabled(); + + await confirmImport(frigateApp.page); + + expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull(); + expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( + STREAMING_SETTINGS, + ); + // a layouts import is what flips this, so it must stay put + expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe( + false, ); }); @@ -325,7 +534,7 @@ test.describe("UI settings import/export @medium", () => { await confirmImport(frigateApp.page); expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( - OUTDOOR_LAYOUT, + frigateApp.isMobile ? null : OUTDOOR_LAYOUT, ); expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( STREAMING_SETTINGS, diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 64289942ce..e5b3499a2d 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -131,6 +131,7 @@ "close": "Close", "expand": "Expand", "collapse": "Collapse", + "clear": "Clear", "copy": "Copy", "copiedToClipboard": "Copied to clipboard", "back": "Back", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index c5fa7afd99..af823a68b1 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 Natural Aspect Ratios", + "desc": "On camera group live view dashboards, size each tile to its camera's own natural 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 stored layout for all camera group live dashboards. Manual reconfiguration will be required." + }, "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." @@ -175,12 +180,14 @@ "storedLayouts": { "title": "Stored Layouts", "desc": "The layout of cameras in a camera group can be dragged/resized. The positions are stored in your browser's local storage.", - "clearAll": "Clear All Layouts" + "clearAll": "Clear All Layouts", + "clearConfirm": "This will clear the stored layout for every camera group in this browser. This cannot be undone." }, "cameraGroupStreaming": { "title": "Camera Group Streaming Settings", "desc": "Streaming settings for each camera group are stored in your browser's local storage.", - "clearAll": "Clear All Streaming Settings" + "clearAll": "Clear All Streaming Settings", + "clearConfirm": "This will clear the streaming settings for every camera group in this browser. This cannot be undone." }, "backupRestore": { "title": "Backup & Restore", @@ -195,6 +202,9 @@ "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)", + "layoutsPhone": "Camera group layouts aren't imported on phones, which always use the standard grid.", + "layoutsModeOn": "These layouts use camera aspect ratio tile sizing, so importing them will also turn on \"Use Natural Aspect Ratios\".", + "layoutsModeOff": "These layouts use standard tile sizing, so importing them will also turn off \"Use Natural 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..c26cc397ee 100644 --- a/web/src/components/overlay/dialog/ImportUiSettingsDialog.tsx +++ b/web/src/components/overlay/dialog/ImportUiSettingsDialog.tsx @@ -10,10 +10,12 @@ 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 { LuFileJson, LuInfo, LuTriangleAlert } from "react-icons/lu"; +import { isMobileOnly } from "react-device-detect"; import FilterSwitch from "@/components/filter/FilterSwitch"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { + importedLayoutsNaturalAspect, ImportSummary, TransferSection, UiSettingsFile, @@ -25,6 +27,7 @@ type ImportUiSettingsDialogProps = { fileName: string; file: UiSettingsFile; summary: ImportSummary; + currentNaturalAspect: boolean; onConfirm: (sections: Record) => Promise; }; @@ -34,6 +37,7 @@ export default function ImportUiSettingsDialog({ fileName, file, summary, + currentNaturalAspect, onConfirm, }: ImportUiSettingsDialogProps) { const { t } = useTranslation(["views/settings", "common"]); @@ -41,7 +45,9 @@ export default function ImportUiSettingsDialog({ const available = useMemo( () => ({ - layouts: summary.layoutGroupCount > 0, + // phones use the static grid, so a saved grid layout has nothing to + // apply to and would only flip the tile sizing mode behind the scenes + layouts: !isMobileOnly && summary.layoutGroupCount > 0, streaming: summary.streamingCameraCount > 0, preferences: summary.preferenceCount > 0, }), @@ -90,6 +96,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); @@ -113,73 +129,102 @@ export default function ImportUiSettingsDialog({ -
-

{fileName}

-

- {t("general.backupRestore.importDialog.exportedFrom", { - date: exportedDate, - version: file.frigate_version, - })} -

-
+
+
+ +
+

+ {fileName} +

+

+ {t("general.backupRestore.importDialog.exportedFrom", { + date: exportedDate, + version: file.frigate_version, + })} +

+
+
-
- - setSections((prev) => ({ ...prev, layouts: checked })) - } - /> - - setSections((prev) => ({ ...prev, streaming: checked })) - } - /> - - setSections((prev) => ({ ...prev, preferences: checked })) - } - /> -
+
+ + setSections((prev) => ({ ...prev, layouts: checked })) + } + /> + + setSections((prev) => ({ ...prev, streaming: checked })) + } + /> + + setSections((prev) => ({ ...prev, preferences: checked })) + } + /> +
- {(visibleUnknownGroups.length > 0 || - visibleUnknownCameras.length > 0) && ( - - - - {visibleUnknownGroups.length > 0 && ( -

- {t("general.backupRestore.importDialog.unknownGroups", { - count: visibleUnknownGroups.length, - groups: visibleUnknownGroups.join(", "), - })} -

- )} - {visibleUnknownCameras.length > 0 && ( -

- {t("general.backupRestore.importDialog.unknownCameras", { - count: visibleUnknownCameras.length, - cameras: visibleUnknownCameras.join(", "), - })} -

- )} -
-
- )} + {isMobileOnly && summary.layoutGroupCount > 0 && ( + + + + {t("general.backupRestore.importDialog.layoutsPhone")} + + + )} + + {layoutsModeChange !== null && ( + + + + {t( + layoutsModeChange + ? "general.backupRestore.importDialog.layoutsModeOn" + : "general.backupRestore.importDialog.layoutsModeOff", + )} + + + )} + + {(visibleUnknownGroups.length > 0 || + visibleUnknownCameras.length > 0) && ( + + + + {visibleUnknownGroups.length > 0 && ( +

+ {t("general.backupRestore.importDialog.unknownGroups", { + count: visibleUnknownGroups.length, + groups: visibleUnknownGroups.join(", "), + })} +

+ )} + {visibleUnknownCameras.length > 0 && ( +

+ {t("general.backupRestore.importDialog.unknownCameras", { + count: visibleUnknownCameras.length, + cameras: visibleUnknownCameras.join(", "), + })} +

+ )} +
+
+ )} +
@@ -435,7 +525,7 @@ export default function UiSettingsView() { id="camera-group-streaming-clear" aria-label={t("general.cameraGroupStreaming.clearAll")} className="w-full md:w-auto" - onClick={clearStreamingSettings} + onClick={() => setPendingConfirm("streaming")} > {t("general.cameraGroupStreaming.clearAll")} @@ -569,9 +659,41 @@ export default function UiSettingsView() { fileName={pendingImport.name} file={pendingImport.file} summary={pendingImport.summary} + currentNaturalAspect={naturalAspect ?? false} onConfirm={handleImportConfirm} /> )} + + { + if (!open) { + setPendingConfirm(null); + } + }} + > + + + + {pendingConfirm && confirmCopy(pendingConfirm).title} + + + {pendingConfirm && confirmCopy(pendingConfirm).description} + + + + + {t("button.cancel", { ns: "common" })} + + + {pendingConfirm && confirmCopy(pendingConfirm).action} + + + +
); }