hide natural aspect and layout import on phones

This commit is contained in:
Josh Hawkins
2026-09-24 13:27:29 -05:00
parent e760868048
commit 5ddcf96756
5 changed files with 163 additions and 96 deletions
@@ -181,6 +181,7 @@ test.describe("UI settings import/export @medium", () => {
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.
@@ -216,6 +217,7 @@ test.describe("UI settings import/export @medium", () => {
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
@@ -256,6 +258,7 @@ test.describe("UI settings import/export @medium", () => {
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(
@@ -314,12 +317,15 @@ test.describe("UI settings import/export @medium", () => {
await expect(
frigateApp.page.getByText("UI preferences (2 settings)"),
).toBeVisible();
await expect(frigateApp.page.getByText(/patio/)).toBeVisible();
// patio is layout-only, so its warning follows the layouts section
await expect(frigateApp.page.getByText(/patio/)).toBeVisible({
visible: !frigateApp.isMobile,
});
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT,
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
);
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS,
@@ -330,6 +336,7 @@ test.describe("UI settings import/export @medium", () => {
test("hides the unknown-group warning when layouts are switched off", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Layout import is desktop and tablet only");
await frigateApp.goto("/settings?page=uiSettings");
// patio is a layout-only group absent from this server, so the warning
@@ -359,7 +366,39 @@ test.describe("UI settings import/export @medium", () => {
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({});
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT,
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
);
});
test("phones refuse layouts and say why @mobile", async ({ frigateApp }) => {
test.skip(!frigateApp.isMobile, "Phone-only");
await frigateApp.goto("/settings?page=uiSettings");
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": false });
await chooseImportFile(frigateApp.page, importPayload());
await expect(
frigateApp.page.getByText(/aren't imported on phones/),
).toBeVisible();
// the section is still listed, but cannot be switched on
await expect(
frigateApp.page.getByText("Camera group layouts (2 groups)"),
).toBeVisible();
await expect(
frigateApp.page.locator('[id="Camera group layouts (2 groups)"]'),
).toBeDisabled();
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toBeNull();
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS,
);
// a layouts import is what flips this, so it must stay put
expect(await readIdb(frigateApp.page, "naturalAspectLayout:admin")).toBe(
false,
);
});
@@ -449,7 +488,7 @@ test.describe("UI settings import/export @medium", () => {
await confirmImport(frigateApp.page);
expect(await readIdb(frigateApp.page, OUTDOOR_LAYOUT_KEY)).toEqual(
OUTDOOR_LAYOUT,
frigateApp.isMobile ? null : OUTDOOR_LAYOUT,
);
expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual(
STREAMING_SETTINGS,
@@ -200,6 +200,7 @@
"desc": "Choose what to apply from this file. Frigate will reload when the import finishes.",
"exportedFrom": "Exported {{date}} from Frigate config version {{version}}",
"layouts_one": "Camera group layouts ({{count}} group)",
"layoutsPhone": "Camera group layouts aren't imported on phones, which always use the standard grid.",
"layoutsModeOn": "These layouts use camera aspect ratio tile sizing, so importing them will also turn on \"Use Camera Aspect Ratios\".",
"layoutsModeOff": "These layouts use standard tile sizing, so importing them will also turn off \"Use Camera Aspect Ratios\".",
"layouts_other": "Camera group layouts ({{count}} groups)",
@@ -10,7 +10,8 @@ import {
} from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { LuInfo, LuTriangleAlert } from "react-icons/lu";
import { LuFileJson, LuInfo, LuTriangleAlert } from "react-icons/lu";
import { isMobileOnly } from "react-device-detect";
import FilterSwitch from "@/components/filter/FilterSwitch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
@@ -44,7 +45,9 @@ export default function ImportUiSettingsDialog({
const available = useMemo(
() => ({
layouts: summary.layoutGroupCount > 0,
// phones use the static grid, so a saved grid layout has nothing to
// apply to and would only flip the tile sizing mode behind the scenes
layouts: !isMobileOnly && summary.layoutGroupCount > 0,
streaming: summary.streamingCameraCount > 0,
preferences: summary.preferenceCount > 0,
}),
@@ -126,87 +129,103 @@ export default function ImportUiSettingsDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-0.5">
<p className="break-all text-base text-primary-variant">{fileName}</p>
<p className="text-sm text-muted-foreground">
{t("general.backupRestore.importDialog.exportedFrom", {
date: exportedDate,
version: file.frigate_version,
})}
</p>
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-lg bg-secondary p-3">
<LuFileJson className="mt-0.5 size-5 shrink-0 text-secondary-foreground" />
<div className="min-w-0">
<p className="break-all text-base font-medium text-primary-variant">
{fileName}
</p>
<p className="text-xs text-muted-foreground">
{t("general.backupRestore.importDialog.exportedFrom", {
date: exportedDate,
version: file.frigate_version,
})}
</p>
</div>
</div>
<div className="space-y-2.5">
<FilterSwitch
label={t("general.backupRestore.importDialog.layouts", {
count: summary.layoutGroupCount,
})}
isChecked={sections.layouts}
disabled={!available.layouts || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, layouts: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.streaming", {
count: summary.streamingCameraCount,
})}
isChecked={sections.streaming}
disabled={!available.streaming || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, streaming: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.preferences", {
count: summary.preferenceCount,
})}
isChecked={sections.preferences}
disabled={!available.preferences || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, preferences: checked }))
}
/>
</div>
{isMobileOnly && summary.layoutGroupCount > 0 && (
<Alert variant="info">
<LuInfo className="size-5" />
<AlertDescription>
{t("general.backupRestore.importDialog.layoutsPhone")}
</AlertDescription>
</Alert>
)}
{layoutsModeChange !== null && (
<Alert variant="info">
<LuInfo className="size-5" />
<AlertDescription>
{t(
layoutsModeChange
? "general.backupRestore.importDialog.layoutsModeOn"
: "general.backupRestore.importDialog.layoutsModeOff",
)}
</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>
<div className="space-y-3">
<FilterSwitch
label={t("general.backupRestore.importDialog.layouts", {
count: summary.layoutGroupCount,
})}
isChecked={sections.layouts}
disabled={!available.layouts || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, layouts: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.streaming", {
count: summary.streamingCameraCount,
})}
isChecked={sections.streaming}
disabled={!available.streaming || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, streaming: checked }))
}
/>
<FilterSwitch
label={t("general.backupRestore.importDialog.preferences", {
count: summary.preferenceCount,
})}
isChecked={sections.preferences}
disabled={!available.preferences || isImporting}
onCheckedChange={(checked) =>
setSections((prev) => ({ ...prev, preferences: checked }))
}
/>
</div>
{layoutsModeChange !== null && (
<Alert variant="info">
<LuInfo className="size-5" />
<AlertDescription>
{t(
layoutsModeChange
? "general.backupRestore.importDialog.layoutsModeOn"
: "general.backupRestore.importDialog.layoutsModeOff",
)}
</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>
)}
<DialogFooter>
<Button
aria-label={t("button.cancel", { ns: "common" })}
@@ -224,7 +243,7 @@ export default function ImportUiSettingsDialog({
>
{isImporting ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<ActivityIndicator className="size-4" />
<span>{t("general.backupRestore.importDialog.confirm")}</span>
</div>
) : (
+4 -2
View File
@@ -33,7 +33,7 @@ import { ASPECT_WIDE_LAYOUT } from "@/types/record";
import { Skeleton } from "@/components/ui/skeleton";
import { isEqual } from "lodash";
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 LivePlayer from "@/components/player/LivePlayer";
import { IoClose } from "react-icons/io5";
@@ -152,7 +152,9 @@ export default function DraggableGridLayout({
"naturalAspectLayout",
false,
);
const naturalAspectLayout = naturalAspectSetting ?? 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.
+14 -8
View File
@@ -19,6 +19,7 @@ import {
useUserPersistence,
deleteUserNamespacedKey,
} from "@/hooks/use-user-persistence";
import { isMobileOnly, isSafari } from "react-device-detect";
import {
Select,
SelectContent,
@@ -368,14 +369,19 @@ export default function UiSettingsView() {
checked: cameraNames,
onCheckedChange: setCameraName,
},
{
id: "natural-aspect",
label: t("general.liveDashboard.naturalAspectLayout.label"),
description: t("general.liveDashboard.naturalAspectLayout.desc"),
note: t("general.liveDashboard.naturalAspectLayout.descNote"),
checked: naturalAspect,
onCheckedChange: setNaturalAspect,
},
// 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"),
note: t("general.liveDashboard.naturalAspectLayout.descNote"),
checked: naturalAspect,
onCheckedChange: setNaturalAspect,
},
]),
];
return (