diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index c9c1993cb..d7e0fa0e6 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -501,6 +501,13 @@ "inherit": "Inherit", "enabled": "Enabled", "disabled": "Disabled" + }, + "cameraType": { + "title": "Camera Type", + "label": "Camera type", + "description": "Set the type for each camera. Dedicated LPR cameras are single-purpose cameras with powerful optical zoom to capture license plates on distant vehicles. Most cameras should use the normal camera type unless the camera is specifically for LPR and has a tightly focused view on license plates.", + "normal": "Normal", + "dedicatedLpr": "Dedicated LPR" } }, "cameraReview": { diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 472aee923..c4cbc67c5 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -14,8 +14,10 @@ import { useTranslation } from "react-i18next"; import CameraEditForm from "@/components/settings/CameraEditForm"; import CameraWizardDialog from "@/components/settings/CameraWizardDialog"; import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog"; -import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; +import { LuExternalLink, LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; import { IoMdArrowRoundBack } from "react-icons/io"; +import { Link } from "react-router-dom"; +import { useDocDomain } from "@/hooks/use-doc-domain"; import { isDesktop } from "react-device-detect"; import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel"; import { Switch } from "@/components/ui/switch"; @@ -89,6 +91,13 @@ export default function CameraManagementView({ return []; }, [config]); + const allCameras = useMemo(() => { + if (config) { + return Object.keys(config.cameras).sort(); + } + return []; + }, [config]); + useEffect(() => { document.title = t("documentTitle.cameraManagement"); }, [t]); @@ -235,6 +244,15 @@ export default function CameraManagementView({ onConfigChanged={updateConfig} /> )} + + {config?.lpr?.enabled && allCameras.length > 0 && ( + + )} ) : ( @@ -497,6 +515,196 @@ function CameraConfigEnableSwitch({ ); } +type CameraTypeSectionProps = { + cameras: string[]; + config: FrigateConfig | undefined; + onConfigChanged: () => Promise; + setRestartDialogOpen: React.Dispatch>; +}; + +function CameraTypeSection({ + cameras, + config, + onConfigChanged, + setRestartDialogOpen, +}: CameraTypeSectionProps) { + const { t } = useTranslation([ + "views/settings", + "common", + "components/dialog", + ]); + const { getLocaleDocUrl } = useDocDomain(); + const [savingCamera, setSavingCamera] = useState(null); + // Optimistic local state: the parsed config API doesn't reflect type + // changes until Frigate restarts, so we track saved values locally. + const [localOverrides, setLocalOverrides] = useState>( + {}, + ); + + const handleTypeChange = useCallback( + async (camera: string, value: string) => { + setSavingCamera(camera); + try { + const typeValue = value === "lpr" ? "lpr" : null; + await axios.put("config/set", { + requires_restart: 1, + config_data: { + cameras: { + [camera]: { + type: typeValue, + }, + }, + }, + }); + await onConfigChanged(); + + setLocalOverrides((prev) => ({ + ...prev, + [camera]: value, + })); + + toast.success( + t("cameraManagement.cameraType.saveSuccess", { + ns: "views/settings", + cameraName: camera, + }), + { + position: "top-center", + action: ( + setRestartDialogOpen(true)}> + + + ), + }, + ); + } catch (error) { + const errorMessage = + axios.isAxiosError(error) && + (error.response?.data?.message || error.response?.data?.detail) + ? error.response?.data?.message || error.response?.data?.detail + : t("toast.save.error.noMessage", { ns: "common" }); + + toast.error( + t("toast.save.error.title", { errorMessage, ns: "common" }), + { position: "top-center" }, + ); + } finally { + setSavingCamera(null); + } + }, + [onConfigChanged, setRestartDialogOpen, t], + ); + + const getCameraType = useCallback( + (camera: string): string => { + const localValue = localOverrides[camera]; + if (localValue) return localValue; + + const type = config?.cameras?.[camera]?.type; + return type === "lpr" ? "lpr" : "normal"; + }, + [config, localOverrides], + ); + + return ( + +
+
+ +

+ {t("cameraManagement.cameraType.description", { + ns: "views/settings", + })} +

+
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+
+
+
+ {cameras.map((camera) => { + const currentType = getCameraType(camera); + const isSaving = savingCamera === camera; + + return ( +
+ + {isSaving ? ( + + ) : ( + + )} +
+ ); + })} +
+

+ {t("cameraManagement.cameraType.description", { + ns: "views/settings", + })} +

+
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+
+
+
+ ); +} + type ProfileCameraEnableSectionProps = { profileState: ProfileState; cameras: string[];