diff --git a/web/e2e/specs/masonry-live-grid.spec.ts b/web/e2e/specs/masonry-live-grid.spec.ts index c00ff10124..76c3f6246a 100644 --- a/web/e2e/specs/masonry-live-grid.spec.ts +++ b/web/e2e/specs/masonry-live-grid.spec.ts @@ -38,8 +38,7 @@ test.describe("Masonry live grid @critical", () => { 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. + // 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 } } }, @@ -47,20 +46,24 @@ test.describe("Masonry live grid @critical", () => { }); 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", ); - // 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); + 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 ({ @@ -210,6 +213,32 @@ test.describe("Masonry live grid @critical", () => { .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, }) => { diff --git a/web/e2e/specs/settings/ui-settings-transfer.spec.ts b/web/e2e/specs/settings/ui-settings-transfer.spec.ts index bcae868054..2716482a3c 100644 --- a/web/e2e/specs/settings/ui-settings-transfer.spec.ts +++ b/web/e2e/specs/settings/ui-settings-transfer.spec.ts @@ -178,6 +178,52 @@ 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, }) => { diff --git a/web/src/utils/uiSettingsTransfer.ts b/web/src/utils/uiSettingsTransfer.ts index e9e7a6b01e..388d209a4a 100644 --- a/web/src/utils/uiSettingsTransfer.ts +++ b/web/src/utils/uiSettingsTransfer.ts @@ -219,6 +219,8 @@ export async function buildExportPayload( const layouts: UiSettingsFile["sections"]["layouts"] = {}; let streaming: UiSettingsFile["sections"]["streaming"] = {}; const preferences: UiSettingsFile["sections"]["preferences"] = {}; + const naturalAspect = + (await readTransferable("naturalAspectLayout", true, username)) === true; await Promise.all( groupNames.map(async (group) => { @@ -228,7 +230,9 @@ export async function buildExportPayload( username, ); - if (value !== undefined) { + // a group not opened since the mode changed still holds a layout from + // the other mode, which the grid discards, so leave it out of the file + if (value !== undefined && layoutIsNatural(value) === naturalAspect) { layouts[group] = value; } }), @@ -399,8 +403,18 @@ 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. +// Bare arrays are pre-masonry bucketed layouts +function layoutIsNatural(layout: unknown): boolean { + return ( + typeof layout === "object" && + layout !== null && + !Array.isArray(layout) && + (layout as { naturalAspect?: unknown }).naturalAspect === true + ); +} + +// A layout only renders under the mode that built it, so importing layouts +// applies this mode too. Exports hold a single mode. export function importedLayoutsNaturalAspect( file: UiSettingsFile, ): boolean | null { @@ -410,9 +424,7 @@ export function importedLayoutsNaturalAspect( return null; } - return layouts.some( - (layout) => !Array.isArray(layout) && layout.naturalAspect === true, - ); + return layouts.some(layoutIsNatural); } export function hasImportableContent(summary: ImportSummary): boolean { diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index 8eb93d655c..c3bb468c0e 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -369,12 +369,18 @@ export default function DraggableGridLayout({ const placed = new Set(existing.map((layout) => layout.i)); 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, + // Each column starts below every existing tile that overlaps it, so new + // cameras fill open columns without overlapping the user's tiles. + const colBottoms = Array.from({ length: tileColumns }, (_, c) => + existing.reduce( + (max, layout) => + layout.x < (c + 1) * TILE_BASE_W && + layout.x + layout.w > c * TILE_BASE_W + ? Math.max(max, layout.y + layout.h) + : max, + 0, + ), ); - const colBottoms = new Array(tileColumns).fill(maxBottom); const result: LayoutItem[] = [...existing]; diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx index 38dc5bb2ca..c6c4e81917 100644 --- a/web/src/views/settings/UiSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -392,7 +392,9 @@ export default function UiSettingsView() { clearStreamingSettings(); break; case "naturalAspect": + // a layout only renders in the mode that built it setNaturalAspect(!naturalAspect); + clearStoredLayouts(); break; }