mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-25 10:46:51 +03:00
hide natural aspect and layout import on phones
This commit is contained in:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user