Compare commits

...
Author SHA1 Message Date
Josh Hawkins 34009aa244 fixes 2026-09-24 13:51:00 -05:00
Josh Hawkins fc39518f71 remove unused 2026-09-24 13:40:25 -05:00
Josh Hawkins 0d6069ccb3 wording tweak 2026-09-24 13:32:57 -05:00
Josh Hawkins bf396e7c4d remove note 2026-09-24 13:27:29 -05:00
Josh Hawkins 2f5c2af8fa clean up 2026-09-24 13:27:29 -05:00
Josh Hawkins f50a785e6a add confirmation dialog for natural aspect switch 2026-09-24 13:27:29 -05:00
Josh Hawkins 2455e8be79 add confirmation dialog for clearing groups and streaming settings 2026-09-24 13:27:29 -05:00
Josh Hawkins 1f5972426e fix merge conflict 2026-09-24 13:27:29 -05:00
Josh Hawkins 5ddcf96756 hide natural aspect and layout import on phones 2026-09-24 13:27:29 -05:00
Josh Hawkins e760868048 rework live dashboard grid layout and add natural mode 2026-09-24 13:26:57 -05:00
007hacky007andGitHub 40f8ba1f7f Offer the full playback rate list on Safari (#24444)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-09-24 10:02:06 -05:00
markfrancisonlyandGitHub 397f5253a5 Rate events over at least one second (#24455)
* Rate events over at least one second

EventsPerSecond.eps() divided the event count by the time since start(),
which can be a few milliseconds right after a restart. Frames buffered
during an ffmpeg restart then report as 100+ fps, and the same happens to
the detector fps. Use a window of at least one second.

* Keep sub-second windows consistent

Floor the divisor at the window length when the window is shorter than a
second, so a caller with a sub-second window still gets its true rate.
2026-09-24 06:28:00 -06:00
23 changed files with 1734 additions and 339 deletions
+25
View File
@@ -36,6 +36,31 @@ class TestEventsPerSecond(unittest.TestCase):
clock[0] += 100.0 clock[0] += 100.0
self.assertEqual(eps.eps(), 0.0) self.assertEqual(eps.eps(), 0.0)
def test_burst_after_start_is_not_divided_by_a_tiny_window(self) -> None:
eps = EventsPerSecond(last_n_seconds=10)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# eleven buffered frames arrive within 100 ms of starting
for _ in range(11):
clock[0] += 0.01
eps.update()
# 11 events over less than a second is at most 11 per second
self.assertLessEqual(eps.eps(), 11.0)
def test_subsecond_window_keeps_its_rate(self) -> None:
eps = EventsPerSecond(last_n_seconds=0.5)
clock = [1000.0]
with patch("frigate.util.builtin.time.monotonic", side_effect=lambda: clock[0]):
eps.start()
# twenty events per second for two seconds
for _ in range(40):
clock[0] += 0.05
eps.update()
# read between events, so none sits exactly on the window edge
clock[0] += 0.01
self.assertAlmostEqual(eps.eps(), 20.0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+7 -4
View File
@@ -56,10 +56,13 @@ class EventsPerSecond:
self._start = now self._start = now
# compute the (approximate) events in the last n seconds # compute the (approximate) events in the last n seconds
self.expire_timestamps(now) self.expire_timestamps(now)
seconds = min(now - self._start, self._last_n_seconds) # rate over at least one second (or the whole window, if shorter),
# avoid divide by zero # so a burst of events right after start() is not divided by a
if seconds == 0: # tiny window
seconds = 1 seconds = max(
min(now - self._start, self._last_n_seconds),
min(1.0, self._last_n_seconds),
)
return len(self._timestamps) / seconds return len(self._timestamps) / seconds
# remove aged out timestamps # remove aged out timestamps
+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">
+2 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { LuFolderX } from "react-icons/lu"; import { LuFolderX } from "react-icons/lu";
import { isMobileOnly, isSafari } from "react-device-detect"; import { isMobileOnly } from "react-device-detect";
import { LuPause, LuPlay } from "react-icons/lu"; import { LuPause, LuPlay } from "react-icons/lu";
import { import {
DropdownMenu, DropdownMenu,
@@ -54,7 +54,7 @@ const CONTROLS_DEFAULT: VideoControls = {
snapshot: false, snapshot: false,
fullscreen: false, fullscreen: false,
}; };
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16]; const PLAYBACK_RATE_DEFAULT = [0.5, 1, 2, 4, 8, 16];
const MIN_ITEMS_WRAP = 6; const MIN_ITEMS_WRAP = 6;
type VideoControlsProps = { type VideoControlsProps = {
@@ -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;
+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)
} }
+126 -5
View File
@@ -10,14 +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 { isSafari } from "react-device-detect"; import { isMobileOnly } from "react-device-detect";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -34,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,
@@ -52,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;
@@ -132,7 +145,7 @@ export default function UiSettingsView() {
const { auth } = useContext(AuthContext); const { auth } = useContext(AuthContext);
const username = auth?.user?.username; const username = auth?.user?.username;
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16]; const PLAYBACK_RATE_DEFAULT = [0.5, 1, 2, 4, 8, 16];
const clearStoredLayouts = useCallback(() => { const clearStoredLayouts = useCallback(() => {
if (!config) { if (!config) {
@@ -322,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);
@@ -330,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",
@@ -352,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 (
@@ -420,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>
@@ -436,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>
@@ -570,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>
); );
} }