Compare commits

...
2 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub f9a37d4f4f remember last selected history sidebar tab (#24488)
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
The History view always opened on Timeline unless the link carried a tab. The selected tab is now saved with useUserPersistence and used as the default when no tab is passed. Notification and shared review links no longer default to "timeline", so they pick up the saved tab too, and `?tab=` still overrides it.
2026-09-27 17:21:46 -06:00
Josh HawkinsandGitHub 0977fa5e15 Improve camera group live view grids (#24461)
* rework live dashboard grid layout and add natural mode

* hide natural aspect and layout import on phones

* fix merge conflict

* add confirmation dialog for clearing groups and streaming settings

* add confirmation dialog for natural aspect switch

* clean up

* remove note

* wording tweak

* remove unused

* fixes
2026-09-27 17:09:31 -06:00
22 changed files with 1713 additions and 335 deletions
+204
View File
@@ -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<string | undefined> {
return page.evaluate(
(suffix) =>
new Promise<string | undefined>((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<string> {
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<void> {
return page.evaluate(
([key, value]) =>
new Promise<void>((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<PersistedLayout | undefined> {
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<PersistedLayout | undefined>;
}
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<Record<string, Box> | null> {
return page.evaluate(
({ cams, target }) => {
const boxes: Record<string, Box> = {};
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<T extends string>(
page: Page,
cameras: readonly T[],
target: BoxTarget = "cell",
): Promise<Record<T, Box>> {
let boxes: Record<string, Box> | 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<T, Box>;
}
+5
View File
@@ -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). */ /** Open the right-click context menu on a camera card (desktop only). */
async openContextMenuOn(cameraName: string): Promise<Locator> { async openContextMenuOn(cameraName: string): Promise<Locator> {
await this.cameraCard(cameraName).first().click({ button: "right" }); await this.cameraCard(cameraName).first().click({ button: "right" });
@@ -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);
});
});
+298
View File
@@ -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);
});
});
@@ -13,7 +13,26 @@ import type { Page } from "@playwright/test";
const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin"; const OUTDOOR_LAYOUT_KEY = "outdoor-draggable-layout:admin";
const STREAMING_KEY = "streaming-settings: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: "front_door", x: 0, y: 0, w: 6, h: 4 },
{ i: "backyard", x: 6, 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); 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 }) => { test("omits settings that were never stored", async ({ frigateApp }) => {
await frigateApp.goto("/settings?page=uiSettings"); await frigateApp.goto("/settings?page=uiSettings");
@@ -190,12 +363,15 @@ test.describe("UI settings import/export @medium", () => {
await expect( await expect(
frigateApp.page.getByText("UI preferences (2 settings)"), frigateApp.page.getByText("UI preferences (2 settings)"),
).toBeVisible(); ).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); await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT, frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
); );
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS, 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 ({ test("hides the unknown-group warning when layouts are switched off", async ({
frigateApp, frigateApp,
}) => { }) => {
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
await frigateApp.goto("/settings?page=uiSettings"); await frigateApp.goto("/settings?page=uiSettings");
// patio is a layout-only group absent from this server, so the warning // 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, STREAMING_KEY)).toEqual({});
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_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); await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual( expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT, frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
); );
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual( expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS, STREAMING_SETTINGS,
+1
View File
@@ -131,6 +131,7 @@
"close": "Close", "close": "Close",
"expand": "Expand", "expand": "Expand",
"collapse": "Collapse", "collapse": "Collapse",
"clear": "Clear",
"copy": "Copy", "copy": "Copy",
"copiedToClipboard": "Copied to clipboard", "copiedToClipboard": "Copied to clipboard",
"back": "Back", "back": "Back",
+12 -2
View File
@@ -167,6 +167,11 @@
"label": "Always Show Camera Names", "label": "Always Show Camera Names",
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard." "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": { "liveFallbackTimeout": {
"label": "Live Player Fallback Timeout", "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." "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": { "storedLayouts": {
"title": "Stored Layouts", "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.", "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": { "cameraGroupStreaming": {
"title": "Camera Group Streaming Settings", "title": "Camera Group Streaming Settings",
"desc": "Streaming settings for each camera group are stored in your browser's local storage.", "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": { "backupRestore": {
"title": "Backup & Restore", "title": "Backup & Restore",
@@ -195,6 +202,9 @@
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.", "desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}", "exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
"layouts_one": "Camera group layouts ({{count}} group)", "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)", "layouts_other": "Camera group layouts ({{count}} groups)",
"streaming_one": "Streaming settings ({{count}} camera)", "streaming_one": "Streaming settings ({{count}} camera)",
"streaming_other": "Streaming settings ({{count}} cameras)", "streaming_other": "Streaming settings ({{count}} cameras)",
+1 -1
View File
@@ -103,7 +103,7 @@ export default function CameraImage({
)} )}
{!imageLoaded && enabled ? ( {!imageLoaded && enabled ? (
<div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center"> <div className="absolute bottom-0 left-0 right-0 top-0 flex items-center justify-center">
<ActivityIndicator /> <ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
</div> </div>
) : null} ) : null}
</div> </div>
@@ -12,13 +12,13 @@ export function ImageShadowOverlay({
<> <>
<div <div
className={cn( className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent", "pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full bg-gradient-to-b from-black/20 to-transparent",
upperClassName, upperClassName,
)} )}
/> />
<div <div
className={cn( className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent", "pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full bg-gradient-to-t from-black/20 to-transparent",
lowerClassName, lowerClassName,
)} )}
/> />
@@ -10,10 +10,12 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; 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 FilterSwitch from "@/components/filter/FilterSwitch";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { import {
importedLayoutsNaturalAspect,
ImportSummary, ImportSummary,
TransferSection, TransferSection,
UiSettingsFile, UiSettingsFile,
@@ -25,6 +27,7 @@ type ImportUiSettingsDialogProps = {
fileName: string; fileName: string;
file: UiSettingsFile; file: UiSettingsFile;
summary: ImportSummary; summary: ImportSummary;
currentNaturalAspect: boolean;
onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>; onConfirm: (sections: Record<TransferSection, boolean>) => Promise<void>;
}; };
@@ -34,6 +37,7 @@ export default function ImportUiSettingsDialog({
fileName, fileName,
file, file,
summary, summary,
currentNaturalAspect,
onConfirm, onConfirm,
}: ImportUiSettingsDialogProps) { }: ImportUiSettingsDialogProps) {
const { t } = useTranslation(["views/settings", "common"]); const { t } = useTranslation(["views/settings", "common"]);
@@ -41,7 +45,9 @@ export default function ImportUiSettingsDialog({
const available = useMemo( 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, streaming: summary.streamingCameraCount > 0,
preferences: summary.preferenceCount > 0, preferences: summary.preferenceCount > 0,
}), }),
@@ -90,6 +96,16 @@ export default function ImportUiSettingsDialog({
[sections.streaming, summary.unknownCameras], [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 () => { const handleConfirm = useCallback(async () => {
setIsImporting(true); setIsImporting(true);
await onConfirm(sections); await onConfirm(sections);
@@ -113,73 +129,102 @@ export default function ImportUiSettingsDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-0.5"> <div className="space-y-4">
<p className="break-all text-base text-primary-variant">{fileName}</p> <div className="flex items-start gap-3 rounded-lg bg-secondary p-3">
<p className="text-sm text-muted-foreground"> <LuFileJson className="mt-0.5 size-5 shrink-0 text-secondary-foreground" />
{t("general.backupRestore.importDialog.exportedFrom", { <div className="min-w-0">
date: exportedDate, <p className="break-all text-base font-medium text-primary-variant">
version: file.frigate_version, {fileName}
})} </p>
</p> <p className="text-xs text-muted-foreground">
</div> {t("general.backupRestore.importDialog.exportedFrom", {
date: exportedDate,
version: file.frigate_version,
})}
</p>
</div>
</div>
<div className="space-y-3"> <div className="space-y-2.5">
<FilterSwitch <FilterSwitch
label={t("general.backupRestore.importDialog.layouts", { label={t("general.backupRestore.importDialog.layouts", {
count: summary.layoutGroupCount, count: summary.layoutGroupCount,
})} })}
isChecked={sections.layouts} isChecked={sections.layouts}
disabled={!available.layouts || isImporting} disabled={!available.layouts || isImporting}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, layouts: checked })) setSections((prev) => ({ ...prev, layouts: checked }))
} }
/> />
<FilterSwitch <FilterSwitch
label={t("general.backupRestore.importDialog.streaming", { label={t("general.backupRestore.importDialog.streaming", {
count: summary.streamingCameraCount, count: summary.streamingCameraCount,
})} })}
isChecked={sections.streaming} isChecked={sections.streaming}
disabled={!available.streaming || isImporting} disabled={!available.streaming || isImporting}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, streaming: checked })) setSections((prev) => ({ ...prev, streaming: checked }))
} }
/> />
<FilterSwitch <FilterSwitch
label={t("general.backupRestore.importDialog.preferences", { label={t("general.backupRestore.importDialog.preferences", {
count: summary.preferenceCount, count: summary.preferenceCount,
})} })}
isChecked={sections.preferences} isChecked={sections.preferences}
disabled={!available.preferences || isImporting} disabled={!available.preferences || isImporting}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, preferences: checked })) setSections((prev) => ({ ...prev, preferences: checked }))
} }
/> />
</div> </div>
{(visibleUnknownGroups.length > 0 || {isMobileOnly && summary.layoutGroupCount > 0 && (
visibleUnknownCameras.length > 0) && ( <Alert variant="info">
<Alert variant="warning"> <LuInfo className="size-5" />
<LuTriangleAlert className="size-5" /> <AlertDescription>
<AlertDescription className="space-y-2"> {t("general.backupRestore.importDialog.layoutsPhone")}
{visibleUnknownGroups.length > 0 && ( </AlertDescription>
<p> </Alert>
{t("general.backupRestore.importDialog.unknownGroups", { )}
count: visibleUnknownGroups.length,
groups: visibleUnknownGroups.join(", "), {layoutsModeChange !== null && (
})} <Alert variant="info">
</p> <LuInfo className="size-5" />
)} <AlertDescription>
{visibleUnknownCameras.length > 0 && ( {t(
<p> layoutsModeChange
{t("general.backupRestore.importDialog.unknownCameras", { ? "general.backupRestore.importDialog.layoutsModeOn"
count: visibleUnknownCameras.length, : "general.backupRestore.importDialog.layoutsModeOff",
cameras: visibleUnknownCameras.join(", "), )}
})} </AlertDescription>
</p> </Alert>
)} )}
</AlertDescription>
</Alert> {(visibleUnknownGroups.length > 0 ||
)} visibleUnknownCameras.length > 0) && (
<Alert variant="warning">
<LuTriangleAlert className="size-5" />
<AlertDescription className="space-y-2">
{visibleUnknownGroups.length > 0 && (
<p>
{t("general.backupRestore.importDialog.unknownGroups", {
count: visibleUnknownGroups.length,
groups: visibleUnknownGroups.join(", "),
})}
</p>
)}
{visibleUnknownCameras.length > 0 && (
<p>
{t("general.backupRestore.importDialog.unknownCameras", {
count: visibleUnknownCameras.length,
cameras: visibleUnknownCameras.join(", "),
})}
</p>
)}
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter> <DialogFooter>
<Button <Button
@@ -198,7 +243,7 @@ export default function ImportUiSettingsDialog({
> >
{isImporting ? ( {isImporting ? (
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<ActivityIndicator /> <ActivityIndicator className="size-4" />
<span>{t("general.backupRestore.importDialog.confirm")}</span> <span>{t("general.backupRestore.importDialog.confirm")}</span>
</div> </div>
) : ( ) : (
@@ -29,22 +29,10 @@ export default function BirdseyeLivePlayer({
}: LivePlayerProps) { }: LivePlayerProps) {
let player; let player;
if (liveMode == "webrtc") { if (liveMode == "webrtc") {
player = ( player = <WebRtcPlayer className="size-full" camera="birdseye" pip={pip} />;
<WebRtcPlayer
className={`size-full rounded-lg md:rounded-2xl`}
camera="birdseye"
pip={pip}
/>
);
} else if (liveMode == "mse") { } else if (liveMode == "mse") {
if ("MediaSource" in window || "ManagedMediaSource" in window) { if ("MediaSource" in window || "ManagedMediaSource" in window) {
player = ( player = <MSEPlayer className="size-full" camera="birdseye" pip={pip} />;
<MSEPlayer
className={`size-full rounded-lg md:rounded-2xl`}
camera="birdseye"
pip={pip}
/>
);
} else { } else {
player = ( player = (
<div className="w-5xl text-center text-sm"> <div className="w-5xl text-center text-sm">
@@ -55,7 +43,7 @@ export default function BirdseyeLivePlayer({
} else if (liveMode == "jsmpeg") { } else if (liveMode == "jsmpeg") {
player = ( player = (
<JSMpegPlayer <JSMpegPlayer
className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl" className="flex size-full justify-center overflow-hidden"
camera="birdseye" camera="birdseye"
width={birdseyeConfig.width} width={birdseyeConfig.width}
height={birdseyeConfig.height} height={birdseyeConfig.height}
@@ -65,22 +53,31 @@ export default function BirdseyeLivePlayer({
/> />
); );
} else { } else {
player = <ActivityIndicator />; player = <ActivityIndicator className="w-full [.bg-black_&]:text-white" />;
} }
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={cn( className={cn(
"relative flex w-full cursor-pointer justify-center", "relative flex w-full cursor-pointer justify-center overflow-hidden rounded-lg md:rounded-2xl",
className, className,
)} )}
onClick={onClick} onClick={onClick}
> >
<ImageShadowOverlay <div
upperClassName="md:rounded-2xl" className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
lowerClassName="md:rounded-2xl" style={
/> {
"--pic-ar":
(birdseyeConfig.width || 1) / (birdseyeConfig.height || 1),
} as React.CSSProperties
}
>
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
<ImageShadowOverlay />
</div>
</div>
<div className="size-full" ref={playerRef}> <div className="size-full" ref={playerRef}>
{player} {player}
</div> </div>
+56 -14
View File
@@ -54,6 +54,7 @@ type LivePlayerProps = {
onError?: (error: LivePlayerError) => void; onError?: (error: LivePlayerError) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void; onMicrophoneError?: (error: TwoWayTalkError) => void;
onResetLiveMode?: () => void; onResetLiveMode?: () => void;
onLiveAspectChange?: (aspectRatio: number | undefined) => void;
}; };
export default function LivePlayer({ export default function LivePlayer({
@@ -80,6 +81,7 @@ export default function LivePlayer({
onError, onError,
onMicrophoneError, onMicrophoneError,
onResetLiveMode, onResetLiveMode,
onLiveAspectChange,
}: LivePlayerProps) { }: LivePlayerProps) {
const { t } = useTranslation(["components/player"]); const { t } = useTranslation(["components/player"]);
@@ -127,6 +129,37 @@ export default function LivePlayer({
// camera live state // camera live state
const [liveReady, setLiveReady] = useState(false); const [liveReady, setLiveReady] = useState(false);
const [liveAspect, setLiveAspect] = useState<number | undefined>();
const handleFullResolution = useCallback(
(value: React.SetStateAction<VideoResolutionType>) => {
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 liveReadyRef = useRef(liveReady);
const cameraActiveRef = useRef(cameraActive); const cameraActiveRef = useRef(cameraActive);
@@ -262,11 +295,12 @@ export default function LivePlayer({
player = ( player = (
<WebRtcPlayer <WebRtcPlayer
key={"webrtc_" + key} key={"webrtc_" + key}
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`} className={`size-full ${liveReady ? "" : "hidden"}`}
camera={streamName} camera={streamName}
playbackEnabled={cameraActive || liveReady} playbackEnabled={cameraActive || liveReady}
getStats={showStats} getStats={showStats}
setStats={setStats} setStats={setStats}
setFullResolution={handleFullResolution}
audioEnabled={playAudio} audioEnabled={playAudio}
volume={volume} volume={volume}
microphoneEnabled={micEnabled} microphoneEnabled={micEnabled}
@@ -282,7 +316,7 @@ export default function LivePlayer({
player = ( player = (
<MSEPlayer <MSEPlayer
key={"mse_" + key} key={"mse_" + key}
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`} className={`size-full ${liveReady ? "" : "hidden"}`}
camera={streamName} camera={streamName}
playbackEnabled={cameraActive || liveReady} playbackEnabled={cameraActive || liveReady}
audioEnabled={playAudio} audioEnabled={playAudio}
@@ -292,7 +326,7 @@ export default function LivePlayer({
setStats={setStats} setStats={setStats}
onPlaying={playerIsPlaying} onPlaying={playerIsPlaying}
pip={pip} pip={pip}
setFullResolution={setFullResolution} setFullResolution={handleFullResolution}
onError={onError} onError={onError}
/> />
); );
@@ -308,7 +342,7 @@ export default function LivePlayer({
player = ( player = (
<JSMpegPlayer <JSMpegPlayer
key={"jsmpeg_" + key} key={"jsmpeg_" + key}
className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl" className="flex justify-center overflow-hidden"
camera={cameraConfig.name} camera={cameraConfig.name}
width={cameraConfig.detect.width} width={cameraConfig.detect.width}
height={cameraConfig.detect.height} height={cameraConfig.detect.height}
@@ -325,7 +359,9 @@ export default function LivePlayer({
player = null; player = null;
} }
} else { } else {
player = <ActivityIndicator />; player = (
<ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
);
} }
return ( return (
@@ -340,10 +376,12 @@ export default function LivePlayer({
}} }}
data-camera={cameraConfig.name} data-camera={cameraConfig.name}
className={cn( 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 && activeTracking &&
((showStillWithoutActivity && !liveReady) || liveReady) ((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", : "outline-0 outline-background",
"transition-all duration-500", "transition-all duration-500",
className, className,
@@ -357,10 +395,14 @@ export default function LivePlayer({
> >
{cameraEnabled && {cameraEnabled &&
((showStillWithoutActivity && !liveReady) || liveReady) && ( ((showStillWithoutActivity && !liveReady) || liveReady) && (
<ImageShadowOverlay <div
upperClassName="md:rounded-2xl" className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center [container-type:size]"
lowerClassName="md:rounded-2xl" style={{ "--pic-ar": pictureAspect } as React.CSSProperties}
/> >
<div className="relative aspect-[var(--pic-ar)] h-auto w-[min(100%,calc(100cqh*var(--pic-ar)))]">
<ImageShadowOverlay />
</div>
</div>
)} )}
{player} {player}
{cameraEnabled && {cameraEnabled &&
@@ -368,7 +410,7 @@ export default function LivePlayer({
(!showStillWithoutActivity || isReEnabling) && (!showStillWithoutActivity || isReEnabling) &&
!liveReady && ( !liveReady && (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<ActivityIndicator /> <ActivityIndicator className={"w-full [.bg-black_&]:text-white"} />
</div> </div>
)} )}
@@ -447,7 +489,7 @@ export default function LivePlayer({
{offline && inDashboard && ( {offline && inDashboard && (
<> <>
<div className="absolute inset-0 rounded-lg bg-black/50 md:rounded-2xl" /> <div className="absolute inset-0 bg-black/50" />
<div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center"> <div className="absolute inset-0 left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center"> <div className="flex flex-col items-center justify-center gap-2 rounded-lg bg-background/50 p-3 text-center">
<div>{t("streamOffline.title")}</div> <div>{t("streamOffline.title")}</div>
@@ -491,7 +533,7 @@ export default function LivePlayer({
)} )}
{!cameraEnabled && ( {!cameraEnabled && (
<div className="relative flex h-full w-full items-center justify-center rounded-2xl border border-secondary-foreground bg-background_alt"> <div className="relative flex h-full w-full items-center justify-center border border-secondary-foreground bg-background_alt">
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48"> <div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
<LuVideoOff className="mb-2 size-8 md:size-10" /> <LuVideoOff className="mb-2 size-8 md:size-10" />
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base"> <p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
@@ -3,6 +3,7 @@ import {
LivePlayerError, LivePlayerError,
PlayerStatsType, PlayerStatsType,
TwoWayTalkError, TwoWayTalkError,
VideoResolutionType,
} from "@/types/live"; } from "@/types/live";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { webRTCIceServers } from "@/utils/webrtcUtil"; import { webRTCIceServers } from "@/utils/webrtcUtil";
@@ -20,6 +21,7 @@ type WebRtcPlayerProps = {
pip?: boolean; pip?: boolean;
getStats?: boolean; getStats?: boolean;
setStats?: (stats: PlayerStatsType) => void; setStats?: (stats: PlayerStatsType) => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onPlaying?: () => void; onPlaying?: () => void;
onError?: (error: LivePlayerError) => void; onError?: (error: LivePlayerError) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void; onMicrophoneError?: (error: TwoWayTalkError) => void;
@@ -36,6 +38,7 @@ export default function WebRtcPlayer({
pip = false, pip = false,
getStats = false, getStats = false,
setStats, setStats,
setFullResolution,
onPlaying, onPlaying,
onError, onError,
onMicrophoneError, onMicrophoneError,
@@ -342,6 +345,12 @@ export default function WebRtcPlayer({
if (videoLoadTimeoutRef.current) { if (videoLoadTimeoutRef.current) {
clearTimeout(videoLoadTimeoutRef.current); clearTimeout(videoLoadTimeoutRef.current);
} }
if (videoRef.current) {
setFullResolution?.({
width: videoRef.current.videoWidth,
height: videoRef.current.videoHeight,
});
}
onPlaying?.(); onPlaying?.();
}; };
+15
View File
@@ -175,6 +175,21 @@ html {
background-image: none !important; 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 { .react-grid-item.react-grid-placeholder {
border: 3px solid #a00000 !important; border: 3px solid #a00000 !important;
opacity: 0.5 !important; opacity: 0.5 !important;
+1 -2
View File
@@ -132,8 +132,7 @@ export default function Events() {
setMotionSearchDay(normalizedDay); setMotionSearchDay(normalizedDay);
}, []); }, []);
const [notificationTab, setNotificationTab] = const [notificationTab, setNotificationTab] = useState<TimelineType>();
useState<TimelineType>("timeline");
const getReviewDayBounds = useCallback((date: Date) => { const getReviewDayBounds = useCallback((date: Date) => {
const now = Date.now() / 1000; const now = Date.now() / 1000;
+63 -2
View File
@@ -54,6 +54,12 @@ export const TRANSFER_KEYS: TransferKey[] = [
namespaced: true, namespaced: true,
schema: z.boolean(), schema: z.boolean(),
}, },
{
key: "naturalAspectLayout",
section: "preferences",
namespaced: true,
schema: z.boolean(),
},
{ {
key: "alertVideos", key: "alertVideos",
section: "preferences", section: "preferences",
@@ -182,13 +188,22 @@ const layoutItemSchema = z
}) })
.passthrough(); .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({ export const uiSettingsFileSchema = z.object({
type: z.literal(UI_SETTINGS_FILE_TYPE), type: z.literal(UI_SETTINGS_FILE_TYPE),
version: z.number().int().positive(), version: z.number().int().positive(),
exported_at: z.string(), exported_at: z.string(),
frigate_version: z.string(), frigate_version: z.string(),
sections: z.object({ sections: z.object({
layouts: z.record(z.string(), z.array(layoutItemSchema)), layouts: z.record(z.string(), storedLayoutSchema),
streaming: allGroupsStreamingSettingsSchema, streaming: allGroupsStreamingSettingsSchema,
preferences: z.record(z.string(), z.unknown()), preferences: z.record(z.string(), z.unknown()),
}), }),
@@ -204,6 +219,8 @@ export async function buildExportPayload(
const layouts: UiSettingsFile["sections"]["layouts"] = {}; const layouts: UiSettingsFile["sections"]["layouts"] = {};
let streaming: UiSettingsFile["sections"]["streaming"] = {}; let streaming: UiSettingsFile["sections"]["streaming"] = {};
const preferences: UiSettingsFile["sections"]["preferences"] = {}; const preferences: UiSettingsFile["sections"]["preferences"] = {};
const naturalAspect =
(await readTransferable("naturalAspectLayout", true, username)) === true;
await Promise.all( await Promise.all(
groupNames.map(async (group) => { groupNames.map(async (group) => {
@@ -213,7 +230,9 @@ export async function buildExportPayload(
username, 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; layouts[group] = value;
} }
}), }),
@@ -384,6 +403,30 @@ export function summarizeImport(
}; };
} }
// 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 {
const layouts = Object.values(file.sections.layouts);
if (!layouts.length) {
return null;
}
return layouts.some(layoutIsNatural);
}
export function hasImportableContent(summary: ImportSummary): boolean { export function hasImportableContent(summary: ImportSummary): boolean {
return ( return (
summary.layoutGroupCount > 0 || summary.layoutGroupCount > 0 ||
@@ -398,6 +441,9 @@ export async function applyImportPayload(
username: string | undefined, username: string | undefined,
): Promise<void> { ): Promise<void> {
const writes: Promise<void>[] = []; const writes: Promise<void>[] = [];
const layoutsMode = sections.layouts
? importedLayoutsNaturalAspect(file)
: null;
if (sections.layouts) { if (sections.layouts) {
Object.entries(file.sections.layouts).forEach(([group, layout]) => { Object.entries(file.sections.layouts).forEach(([group, layout]) => {
@@ -408,6 +454,15 @@ export async function applyImportPayload(
), ),
); );
}); });
if (layoutsMode !== null) {
writes.push(
setData(
getUserNamespacedKey("naturalAspectLayout", username),
layoutsMode,
),
);
}
} }
const streamingEntry = TRANSFER_KEYS.find( const streamingEntry = TRANSFER_KEYS.find(
@@ -447,6 +502,12 @@ export async function applyImportPayload(
if (sections.preferences) { if (sections.preferences) {
validPreferenceEntries(file.sections.preferences).forEach( validPreferenceEntries(file.sections.preferences).forEach(
({ entry, value }) => { ({ 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)); writes.push(setData(storageKey(entry, username), value));
}, },
); );
+374 -209
View File
@@ -8,16 +8,17 @@ import {
import React, { import React, {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { useResizeObserver } from "@/hooks/resize-observer";
import { import {
Layout, Layout,
LayoutItem, LayoutItem,
ResponsiveGridLayout as Responsive, ResponsiveGridLayout as Responsive,
} from "react-grid-layout"; } from "react-grid-layout";
import { aspectRatio, getCompactor } from "react-grid-layout/core";
import "react-grid-layout/css/styles.css"; import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css"; import "react-resizable/css/styles.css";
import { import {
@@ -28,12 +29,11 @@ import {
StatsState, StatsState,
VolumeState, VolumeState,
} from "@/types/live"; } 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 { Skeleton } from "@/components/ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import useSWR from "swr"; import useSWR from "swr";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import LivePlayer from "@/components/player/LivePlayer"; import LivePlayer from "@/components/player/LivePlayer";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
@@ -52,6 +52,43 @@ import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next"; 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 = { type DraggableGridLayoutProps = {
cameras: CameraConfig[]; cameras: CameraConfig[];
cameraGroup: string; cameraGroup: string;
@@ -98,6 +135,66 @@ export default function DraggableGridLayout({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const birdseyeConfig = useMemo(() => config?.birdseye, [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,
);
// phones never reach this grid, and the setting is hidden there, so an
// imported or stale value must not take effect
const naturalAspectLayout = !isMobileOnly && (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 // preferred live modes per camera
const [globalAutoLive] = useUserPersistence("autoLiveView", true); const [globalAutoLive] = useUserPersistence("autoLiveView", true);
@@ -115,7 +212,31 @@ export default function DraggableGridLayout({
// grid layout // grid layout
const [gridLayout, setGridLayout, isGridLayoutLoaded] = const [gridLayout, setGridLayout, isGridLayoutLoaded] =
useUserPersistence<Layout>(`${cameraGroup}-draggable-layout`); useUserPersistence<PersistedLayout>(`${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( const [group] = useUserPersistedOverlayState(
"cameraGroup", "cameraGroup",
@@ -140,11 +261,11 @@ export default function DraggableGridLayout({
useEffect(() => { useEffect(() => {
setIsEditMode(false); setIsEditMode(false);
setEditGroup(false); setEditGroup(false);
// Reset camera tracking state when group changes to prevent the camera-change // Keeps the camera-change effect from overwriting the layout we load next.
// effect from incorrectly overwriting the loaded layout
setCurrentCameras(undefined); setCurrentCameras(undefined);
setCurrentIncludeBirdseye(undefined); setCurrentIncludeBirdseye(undefined);
setCurrentGridLayout(undefined); setCurrentGridLayout(undefined);
setCurrentNaturalAspect(undefined);
}, [cameraGroup, setIsEditMode]); }, [cameraGroup, setIsEditMode]);
// camera state // camera state
@@ -155,21 +276,84 @@ export default function DraggableGridLayout({
const [currentGridLayout, setCurrentGridLayout] = useState< const [currentGridLayout, setCurrentGridLayout] = useState<
Layout | undefined Layout | undefined
>(); >();
const [currentNaturalAspect, setCurrentNaturalAspect] = useState<boolean>();
const handleLayoutChange = useCallback( const handleLayoutChange = useCallback(
(currentLayout: Layout) => { (currentLayout: Layout) => {
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) { if (
!isGridLayoutLoaded ||
!isEqual(readPersistedLayout(gridLayout), currentGridLayout)
) {
return; return;
} }
// save layout to idb setGridLayout(toPersisted(currentLayout));
setGridLayout(currentLayout);
setShowCircles(true); 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( const generateLayout = useCallback(
(baseLayout: Layout | undefined) => { (baseLayout: Layout | undefined): Layout | undefined => {
if (!isGridLayoutLoaded) { if (!isGridLayoutLoaded) {
return; return;
} }
@@ -179,91 +363,98 @@ export default function DraggableGridLayout({
? ["birdseye", ...cameras.map((camera) => camera?.name || "")] ? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
: cameras.map((camera) => camera?.name || ""); : cameras.map((camera) => camera?.name || "");
const optionsMap: LayoutItem[] = baseLayout const existing: LayoutItem[] = baseLayout
? baseLayout.filter((layout) => cameraNames?.includes(layout.i)) ? baseLayout.filter((layout) => cameraNames.includes(layout.i))
: []; : [];
const placed = new Set(existing.map((layout) => layout.i));
cameraNames.forEach((cameraName, index) => { const tileColumns = GRID_COLS / TILE_BASE_W; // 3 standard columns
const existingLayout = optionsMap.find( // Each column starts below every existing tile that overlaps it, so new
(layout) => layout.i === cameraName, // 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,
),
);
// Skip if the camera already exists in the layout const result: LayoutItem[] = [...existing];
if (existingLayout) {
cameraNames.forEach((name) => {
if (placed.has(name)) {
return; return;
} }
const { w, h } = dimsFor(name);
let aspectRatio; if (w === TILE_BASE_W) {
let col; let col = 0;
for (let c = 1; c < tileColumns; c++) {
// Handle "birdseye" camera as a special case if (colBottoms[c] < colBottoms[col]) {
if (cameraName === "birdseye") { col = c;
aspectRatio = }
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1); }
col = 0; // Set birdseye camera in the first column result.push({
i: name,
x: col * TILE_BASE_W,
y: colBottoms[col],
w,
h,
});
colBottoms[col] += h;
} else { } else {
const camera = cameras.find((cam) => cam.name === cameraName); let pair = 0;
aspectRatio = for (let c = 1; c + 1 < tileColumns; c++) {
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9; if (
col = index % 3; // Regular cameras distributed across columns 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(() => { useEffect(() => {
if (isGridLayoutLoaded) { if (!isGridLayoutLoaded) {
if (gridLayout) { return;
// set current grid layout from loaded, possibly adding new cameras }
const updatedLayout = generateLayout(gridLayout);
setCurrentGridLayout(updatedLayout); const saved = readPersistedLayout(gridLayout);
// Only save if cameras were added (layout changed) const converted = saved ? undefined : convertLegacyLayout(gridLayout);
if (!isEqual(updatedLayout, gridLayout)) { const base = saved ?? converted;
setGridLayout(updatedLayout);
} if (base) {
// Set camera tracking state so the camera-change effect has a baseline const updatedLayout = generateLayout(base) ?? base;
setCurrentCameras(cameras); setCurrentGridLayout(updatedLayout);
setCurrentIncludeBirdseye(includeBirdseye); if (converted || !isEqual(updatedLayout, base)) {
} else { setGridLayout(toPersisted(updatedLayout));
// idb is empty, set it with an initial layout
const newLayout = generateLayout(undefined);
setCurrentGridLayout(newLayout);
setGridLayout(newLayout);
setCurrentCameras(cameras);
setCurrentIncludeBirdseye(includeBirdseye);
} }
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, gridLayout,
@@ -272,12 +463,15 @@ export default function DraggableGridLayout({
generateLayout, generateLayout,
cameras, cameras,
includeBirdseye, includeBirdseye,
naturalAspectLayout,
readPersistedLayout,
convertLegacyLayout,
toPersisted,
]); ]);
useEffect(() => { useEffect(() => {
// Only regenerate layout when cameras change WITHIN an already-loaded group // Only for camera changes within a loaded group; undefined currentCameras
// Skip if currentCameras is undefined (means we just switched groups and // means the load effect above has not run yet.
// the first useEffect hasn't run yet to set things up)
if (!isGridLayoutLoaded || currentCameras === undefined) { if (!isGridLayoutLoaded || currentCameras === undefined) {
return; return;
} }
@@ -289,10 +483,10 @@ export default function DraggableGridLayout({
setCurrentCameras(cameras); setCurrentCameras(cameras);
setCurrentIncludeBirdseye(includeBirdseye); setCurrentIncludeBirdseye(includeBirdseye);
// Regenerate layout based on current layout, adding any new cameras const updatedLayout =
const updatedLayout = generateLayout(currentGridLayout); generateLayout(currentGridLayout) ?? currentGridLayout ?? [];
setCurrentGridLayout(updatedLayout); setCurrentGridLayout(updatedLayout);
setGridLayout(updatedLayout); setGridLayout(toPersisted(updatedLayout));
} }
}, [ }, [
cameras, cameras,
@@ -303,39 +497,47 @@ export default function DraggableGridLayout({
generateLayout, generateLayout,
setGridLayout, setGridLayout,
isGridLayoutLoaded, 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 setCurrentNaturalAspect(naturalAspectLayout);
useLayoutEffect(() => { const regenerated = generateLayout(undefined) ?? [];
const calculateRemValue = () => { setCurrentGridLayout(regenerated);
const htmlElement = document.documentElement; setGridLayout(toPersisted(regenerated));
const fontSize = window.getComputedStyle(htmlElement).fontSize; }, [
setMarginValue(parseFloat(fontSize)); naturalAspectLayout,
}; isNaturalAspectLoaded,
currentNaturalAspect,
generateLayout,
setGridLayout,
toPersisted,
]);
calculateRemValue(); const gridContainerRef = useRef<HTMLDivElement | null>(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<number | null>(null);
const attachGridContainer = useCallback((node: HTMLDivElement | null) => {
gridContainerRef.current = node;
setMountWidth(node ? node.getBoundingClientRect().width : null);
}, []); }, []);
const gridContainerRef = useRef<HTMLDivElement>(null);
const [{ width: containerWidth, height: containerHeight }] = const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(gridContainerRef); useResizeObserver(gridContainerRef);
const scrollBarWidth = useMemo(() => { const availableWidth = containerWidth || mountWidth || 0;
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 hasScrollbar = useMemo(() => { const hasScrollbar = useMemo(() => {
if (containerHeight && containerRef.current) { if (containerHeight && containerRef.current) {
@@ -346,61 +548,10 @@ export default function DraggableGridLayout({
}, [containerRef, containerHeight]); }, [containerRef, containerHeight]);
const cellHeight = useMemo(() => { const cellHeight = useMemo(() => {
const aspectRatio = 16 / 9; const width = availableWidth || window.innerWidth;
// subtract container margin, 1 camera takes up at least 4 rows const columnWidth = width / GRID_COLS;
// account for additional margin on bottom of each row return columnWidth / VERTICAL_RESOLUTION;
return ( }, [availableWidth]);
((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;
};
// audio and stats states // audio and stats states
@@ -503,6 +654,19 @@ export default function DraggableGridLayout({
onSaveMuting(true); 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 ( return (
<> <>
<Toaster position="top-center" closeButton={true} /> <Toaster position="top-center" closeButton={true} />
@@ -525,8 +689,8 @@ export default function DraggableGridLayout({
</div> </div>
) : ( ) : (
<div <div
className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8" className="no-scrollbar my-2 select-none overflow-x-hidden pb-8"
ref={gridContainerRef} ref={attachGridContainer}
> >
<EditGroupDialog <EditGroupDialog
open={editGroup} open={editGroup}
@@ -536,28 +700,36 @@ export default function DraggableGridLayout({
/> />
<Responsive <Responsive
className="grid-layout" className="grid-layout"
width={availableWidth ?? window.innerWidth} width={availableWidth || window.innerWidth}
layouts={{ layouts={{
lg: currentGridLayout, lg: layoutWithConstraints,
md: currentGridLayout, md: layoutWithConstraints,
sm: currentGridLayout, sm: layoutWithConstraints,
xs: currentGridLayout, xs: layoutWithConstraints,
xxs: currentGridLayout, xxs: layoutWithConstraints,
}} }}
rowHeight={cellHeight} rowHeight={cellHeight}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }} cols={{
margin={[marginValue, marginValue]} lg: GRID_COLS,
md: GRID_COLS,
sm: GRID_COLS,
xs: GRID_COLS,
xxs: GRID_COLS,
}}
margin={[0, 0]}
compactor={FREE_PLACEMENT_COMPACTOR}
containerPadding={[0, isEditMode ? 6 : 3]} containerPadding={[0, isEditMode ? 6 : 3]}
resizeConfig={{ resizeConfig={{
enabled: isEditMode, enabled: isEditMode,
handles: isEditMode ? ["sw", "nw", "se", "ne"] : [], // se only: top/left handles fight the aspect constraint at a grid
// boundary (RGL re-clamps the opposite edge) and distort the tile.
handles: isEditMode ? ["se"] : [],
}} }}
dragConfig={{ dragConfig={{
enabled: isEditMode, enabled: isEditMode,
}} }}
onDragStop={handleLayoutChange} onDragStop={handleLayoutChange}
onResize={handleResize}
onResizeStart={() => setShowCircles(false)} onResizeStart={() => setShowCircles(false)}
onResizeStop={handleLayoutChange} onResizeStop={handleLayoutChange}
> >
@@ -570,22 +742,12 @@ export default function DraggableGridLayout({
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing", "outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
)} )}
birdseyeConfig={birdseyeConfig} birdseyeConfig={birdseyeConfig}
aspectRatio={layoutAspects["birdseye"] ?? DEFAULT_ASPECT}
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"} liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
onClick={() => onSelectCamera("birdseye")} onClick={() => onSelectCamera("birdseye")}
> ></BirdseyeLivePlayerGridItem>
{isEditMode && showCircles && <CornerCircles />}
</BirdseyeLivePlayerGridItem>
)} )}
{cameras.map((camera) => { {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 availableStreams = camera.live.streams || {};
const firstStreamEntry = Object.values(availableStreams)[0] || ""; const firstStreamEntry = Object.values(availableStreams)[0] || "";
@@ -614,7 +776,14 @@ export default function DraggableGridLayout({
?.compatibilityMode || false; ?.compatibilityMode || false;
return ( return (
<GridLiveContextMenu <GridLiveContextMenu
className={grow} className={CARD_FIT}
aspectRatio={
(naturalAspectLayout
? liveAspects[camera.name]
: undefined) ??
layoutAspects[camera.name] ??
DEFAULT_ASPECT
}
key={camera.name} key={camera.name}
camera={camera.name} camera={camera.name}
streamName={streamName} streamName={streamName}
@@ -653,8 +822,8 @@ export default function DraggableGridLayout({
useWebGL={useWebGL} useWebGL={useWebGL}
cameraRef={cameraRef} cameraRef={cameraRef}
className={cn( className={cn(
"rounded-lg bg-black md:rounded-2xl", "size-full",
grow, naturalAspectLayout ? "bg-background" : "bg-black",
isEditMode && isEditMode &&
showCircles && showCircles &&
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing", "outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
@@ -675,8 +844,8 @@ export default function DraggableGridLayout({
onResetLiveMode={() => resetPreferredLiveMode(camera.name)} onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name]} playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name]} volume={volumeStates[camera.name]}
onLiveAspectChange={liveAspectHandlers[camera.name]}
/> />
{isEditMode && showCircles && <CornerCircles />}
</GridLiveContextMenu> </GridLiveContextMenu>
); );
})} })}
@@ -694,6 +863,7 @@ export default function DraggableGridLayout({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <div
data-testid="toggle-edit-layout"
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100" className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
onClick={() => onClick={() =>
setIsEditMode((prevIsEditMode) => !prevIsEditMode) setIsEditMode((prevIsEditMode) => !prevIsEditMode)
@@ -762,17 +932,6 @@ export default function DraggableGridLayout({
); );
} }
function CornerCircles() {
return (
<>
<div className="pointer-events-none absolute left-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
<div className="pointer-events-none absolute right-[-4px] top-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
<div className="pointer-events-none absolute bottom-[-4px] right-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
<div className="pointer-events-none absolute bottom-[-4px] left-[-4px] z-50 size-3 rounded-full bg-primary-variant p-2 text-background outline-2 outline-muted" />
</>
);
}
type BirdseyeLivePlayerGridItemProps = { type BirdseyeLivePlayerGridItemProps = {
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string; className?: string;
@@ -781,6 +940,7 @@ type BirdseyeLivePlayerGridItemProps = {
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>; onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
children?: React.ReactNode; children?: React.ReactNode;
birdseyeConfig: BirdseyeConfig; birdseyeConfig: BirdseyeConfig;
aspectRatio: number;
liveMode: LivePlayerMode; liveMode: LivePlayerMode;
onClick: () => void; onClick: () => void;
}; };
@@ -798,6 +958,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
onTouchEnd, onTouchEnd,
children, children,
birdseyeConfig, birdseyeConfig,
aspectRatio: cellAspect,
liveMode, liveMode,
onClick, onClick,
...props ...props
@@ -806,7 +967,8 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
) => { ) => {
return ( return (
<div <div
style={{ ...style }} className="flex items-center justify-center p-1 [container-type:size]"
style={{ ...style, "--ar": cellAspect } as React.CSSProperties}
ref={ref} ref={ref}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onMouseUp={onMouseUp} onMouseUp={onMouseUp}
@@ -814,7 +976,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
{...props} {...props}
> >
<BirdseyeLivePlayer <BirdseyeLivePlayer
className={className} className={cn(CARD_FIT, className)}
birdseyeConfig={birdseyeConfig} birdseyeConfig={birdseyeConfig}
liveMode={liveMode} liveMode={liveMode}
onClick={onClick} onClick={onClick}
@@ -829,6 +991,7 @@ const BirdseyeLivePlayerGridItem = React.forwardRef<
type GridLiveContextMenuProps = { type GridLiveContextMenuProps = {
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
aspectRatio?: number;
onMouseDown?: React.MouseEventHandler<HTMLDivElement>; onMouseDown?: React.MouseEventHandler<HTMLDivElement>;
onMouseUp?: React.MouseEventHandler<HTMLDivElement>; onMouseUp?: React.MouseEventHandler<HTMLDivElement>;
onTouchEnd?: React.TouchEventHandler<HTMLDivElement>; onTouchEnd?: React.TouchEventHandler<HTMLDivElement>;
@@ -860,6 +1023,7 @@ const GridLiveContextMenu = React.forwardRef<
{ {
className, className,
style, style,
aspectRatio: cameraAspect,
onMouseDown, onMouseDown,
onMouseUp, onMouseUp,
onTouchEnd, onTouchEnd,
@@ -887,7 +1051,8 @@ const GridLiveContextMenu = React.forwardRef<
) => { ) => {
return ( return (
<div <div
style={{ ...style }} className="flex items-center justify-center p-1 [container-type:size]"
style={{ ...style, "--ar": cameraAspect } as React.CSSProperties}
ref={ref} ref={ref}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onMouseUp={onMouseUp} onMouseUp={onMouseUp}
+1 -1
View File
@@ -295,7 +295,7 @@ export default function LiveBirdseyeView({
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
<BirdseyeLivePlayer <BirdseyeLivePlayer
className={`${fullscreen ? "*:rounded-none" : ""}`} className={fullscreen ? "rounded-none" : ""}
birdseyeConfig={config.birdseye} birdseyeConfig={config.birdseye}
liveMode={preferredLiveMode} liveMode={preferredLiveMode}
containerRef={containerRef} containerRef={containerRef}
+1 -1
View File
@@ -860,7 +860,7 @@ export default function LiveCameraView({
)} )}
<LivePlayer <LivePlayer
key={camera.name} key={camera.name}
className={`${fullscreen ? "*:rounded-none" : ""}`} className={fullscreen ? "rounded-none" : ""}
windowVisible windowVisible
showStillWithoutActivity={false} showStillWithoutActivity={false}
alwaysShowCameraName={false} alwaysShowCameraName={false}
+2 -2
View File
@@ -410,7 +410,7 @@ export default function LiveDashboardView({
return ( return (
<div <div
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2" className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 [scrollbar-gutter:stable] md:p-2"
ref={containerRef} ref={containerRef}
> >
{isMobile && ( {isMobile && (
@@ -609,7 +609,7 @@ export default function LiveDashboardView({
<LivePlayer <LivePlayer
cameraRef={cameraRef} cameraRef={cameraRef}
key={camera.name} key={camera.name}
className={`${grow} rounded-lg bg-black md:rounded-2xl`} className={`${grow} bg-black`}
windowVisible={ windowVisible={
windowVisible && visibleCameras.includes(camera.name) windowVisible && visibleCameras.includes(camera.name)
} }
+13 -2
View File
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useOverlayState } from "@/hooks/use-overlay-state"; import { useOverlayState } from "@/hooks/use-overlay-state";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { DEFAULT_DRAWER_FEATURES, ExportMode } from "@/types/filter"; import { DEFAULT_DRAWER_FEATURES, ExportMode } from "@/types/filter";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
@@ -182,9 +183,19 @@ export function RecordingView({
false, false,
); );
const [timelineType, setTimelineType] = useOverlayState<TimelineType>( const [savedTimelineType, setSavedTimelineType] =
useUserPersistence<TimelineType>("recordingTimelineType");
const [timelineType, setOverlayTimelineType] = useOverlayState<TimelineType>(
"timelineType", "timelineType",
recording?.timelineType ?? "timeline", recording?.timelineType ?? savedTimelineType ?? "timeline",
);
const setTimelineType = useCallback(
(value: TimelineType, replace?: boolean) => {
setOverlayTimelineType(value, replace);
setSavedTimelineType(value);
},
[setOverlayTimelineType, setSavedTimelineType],
); );
const chunkedTimeRange = useMemo( const chunkedTimeRange = useMemo(
+125 -3
View File
@@ -10,13 +10,14 @@ import {
useState, useState,
} from "react"; } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "../../components/ui/button"; import { Button, buttonVariants } from "../../components/ui/button";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { import {
useUserPersistence, useUserPersistence,
deleteUserNamespacedKey, deleteUserNamespacedKey,
} from "@/hooks/use-user-persistence"; } from "@/hooks/use-user-persistence";
import { isMobileOnly } from "react-device-detect";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -33,6 +34,17 @@ import {
CONTROL_COLUMN_CLASS_NAME, CONTROL_COLUMN_CLASS_NAME,
} from "@/components/card/SettingsGroupCard"; } from "@/components/card/SettingsGroupCard";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { cn } from "@/lib/utils";
import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog"; import ImportUiSettingsDialog from "@/components/overlay/dialog/ImportUiSettingsDialog";
import { import {
applyImportPayload, applyImportPayload,
@@ -51,6 +63,8 @@ import {
const WEEK_STARTS_ON = ["Sunday", "Monday"]; const WEEK_STARTS_ON = ["Sunday", "Monday"];
const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed"; const IMPORT_FAILED_FLAG = "frigate-ui-settings-import-failed";
type ConfirmTarget = "layouts" | "streaming" | "naturalAspect";
type SwitchSettingRowProps = { type SwitchSettingRowProps = {
id: string; id: string;
label: string; label: string;
@@ -321,6 +335,10 @@ export default function UiSettingsView() {
"displayCameraNames", "displayCameraNames",
false, false,
); );
const [naturalAspect, setNaturalAspect] = useUserPersistence(
"naturalAspectLayout",
false,
);
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1); const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0); const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true); const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
@@ -329,6 +347,66 @@ export default function UiSettingsView() {
3, 3,
); );
const [pendingConfirm, setPendingConfirm] = useState<ConfirmTarget | null>(
null,
);
const confirmCopy = useCallback(
(target: ConfirmTarget) => {
// literal keys per branch: a template key would be invisible to
// npm run i18n:extract, which CI verifies
switch (target) {
case "layouts":
return {
title: t("general.storedLayouts.clearAll"),
description: t("general.storedLayouts.clearConfirm"),
action: t("button.clear", { ns: "common" }),
};
case "streaming":
return {
title: t("general.cameraGroupStreaming.clearAll"),
description: t("general.cameraGroupStreaming.clearConfirm"),
action: t("button.clear", { ns: "common" }),
};
case "naturalAspect":
return {
title: t("general.liveDashboard.naturalAspectLayout.label"),
description: t(
"general.liveDashboard.naturalAspectLayout.descNote",
),
action: naturalAspect
? t("button.disable", { ns: "common" })
: t("button.enable", { ns: "common" }),
};
}
},
[naturalAspect, t],
);
const handleConfirm = useCallback(() => {
switch (pendingConfirm) {
case "layouts":
clearStoredLayouts();
break;
case "streaming":
clearStreamingSettings();
break;
case "naturalAspect":
// a layout only renders in the mode that built it
setNaturalAspect(!naturalAspect);
clearStoredLayouts();
break;
}
setPendingConfirm(null);
}, [
pendingConfirm,
clearStoredLayouts,
clearStreamingSettings,
naturalAspect,
setNaturalAspect,
]);
const liveDashboardSwitchRows = [ const liveDashboardSwitchRows = [
{ {
id: "auto-live", id: "auto-live",
@@ -351,6 +429,18 @@ export default function UiSettingsView() {
checked: cameraNames, checked: cameraNames,
onCheckedChange: setCameraName, onCheckedChange: setCameraName,
}, },
// phones use the static grid, so tile sizing has nothing to affect there
...(isMobileOnly
? []
: [
{
id: "natural-aspect",
label: t("general.liveDashboard.naturalAspectLayout.label"),
description: t("general.liveDashboard.naturalAspectLayout.desc"),
checked: naturalAspect,
onCheckedChange: () => setPendingConfirm("naturalAspect"),
},
]),
]; ];
return ( return (
@@ -419,7 +509,7 @@ export default function UiSettingsView() {
id="stored-layouts-clear" id="stored-layouts-clear"
aria-label={t("general.storedLayouts.clearAll")} aria-label={t("general.storedLayouts.clearAll")}
className="w-full md:w-auto" className="w-full md:w-auto"
onClick={clearStoredLayouts} onClick={() => setPendingConfirm("layouts")}
> >
{t("general.storedLayouts.clearAll")} {t("general.storedLayouts.clearAll")}
</Button> </Button>
@@ -435,7 +525,7 @@ export default function UiSettingsView() {
id="camera-group-streaming-clear" id="camera-group-streaming-clear"
aria-label={t("general.cameraGroupStreaming.clearAll")} aria-label={t("general.cameraGroupStreaming.clearAll")}
className="w-full md:w-auto" className="w-full md:w-auto"
onClick={clearStreamingSettings} onClick={() => setPendingConfirm("streaming")}
> >
{t("general.cameraGroupStreaming.clearAll")} {t("general.cameraGroupStreaming.clearAll")}
</Button> </Button>
@@ -569,9 +659,41 @@ export default function UiSettingsView() {
fileName={pendingImport.name} fileName={pendingImport.name}
file={pendingImport.file} file={pendingImport.file}
summary={pendingImport.summary} summary={pendingImport.summary}
currentNaturalAspect={naturalAspect ?? false}
onConfirm={handleImportConfirm} onConfirm={handleImportConfirm}
/> />
)} )}
<AlertDialog
open={pendingConfirm != null}
onOpenChange={(open) => {
if (!open) {
setPendingConfirm(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingConfirm && confirmCopy(pendingConfirm).title}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingConfirm && confirmCopy(pendingConfirm).description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleConfirm}
>
{pendingConfirm && confirmCopy(pendingConfirm).action}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }