From d3af748366ff8f880771d57a3ee0743f79d9a467 Mon Sep 17 00:00:00 2001 From: GuoQing Liu <842607283@qq.com> Date: Wed, 27 Aug 2025 01:15:01 +0800 Subject: [PATCH] feat: Add camera nickname (#19567) * refactor: Refactor camera nickname * fix: fix cameraNameLabel visually * chore: The Explore search function also displays the Camera's nickname in English * chore: add mobile page camera nickname * feat: webpush support camera nickname * fix: fix storage camera name is null * chore: fix review detail and context menu camera nickname * chore: fix use-stats and notification setting camera nickname * fix: fix stats camera if not nickname need capitalize * fix: fix debug page open camera web ui i18n and camera nickname support * fix: fix camera metrics not use nickname * refactor: refactor use-camera-nickname hook. --- frigate/comms/webpush.py | 16 +++-- frigate/config/camera/camera.py | 12 +++- frigate/storage.py | 5 +- web/public/locales/en/components/camera.json | 1 + web/public/locales/en/views/settings.json | 4 +- web/src/components/camera/CameraNameLabel.tsx | 24 +++++++ .../components/filter/CameraGroupSelector.tsx | 9 ++- .../components/filter/CamerasFilterButton.tsx | 3 +- web/src/components/filter/FilterSwitch.tsx | 23 +++++-- web/src/components/input/InputWithTags.tsx | 33 +++++++--- web/src/components/menu/LiveContextMenu.tsx | 3 +- .../components/overlay/CameraInfoDialog.tsx | 5 +- .../overlay/CreateTriggerDialog.tsx | 7 ++- .../components/overlay/MobileCameraDrawer.tsx | 3 +- .../overlay/detail/ReviewDetailDialog.tsx | 3 +- .../overlay/detail/SearchDetailDialog.tsx | 3 +- web/src/components/player/LivePlayer.tsx | 6 +- web/src/components/player/PreviewPlayer.tsx | 7 ++- .../components/settings/CameraEditForm.tsx | 63 +++++++++++++------ .../settings/CameraStreamingDialog.tsx | 5 +- web/src/hooks/use-camera-nickname.ts | 29 +++++++++ web/src/hooks/use-stats.ts | 10 +-- web/src/pages/Settings.tsx | 12 ++-- web/src/types/frigateConfig.ts | 1 + web/src/views/recording/RecordingView.tsx | 3 +- web/src/views/settings/CameraSettingsView.tsx | 36 +++++------ .../settings/FrigatePlusSettingsView.tsx | 5 +- .../settings/NotificationsSettingsView.tsx | 12 ++-- web/src/views/settings/ObjectSettingsView.tsx | 7 ++- web/src/views/settings/TriggerView.tsx | 6 +- web/src/views/system/CameraMetrics.tsx | 19 ++++-- 31 files changed, 276 insertions(+), 99 deletions(-) create mode 100644 web/src/components/camera/CameraNameLabel.tsx create mode 100644 web/src/hooks/use-camera-nickname.ts diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py index 7eb2cd0c2..20d84ed5c 100644 --- a/frigate/comms/webpush.py +++ b/frigate/comms/webpush.py @@ -334,6 +334,9 @@ class WebPushClient(Communicator): return camera: str = payload["after"]["camera"] + camera_name: str = getattr( + self.config.cameras[camera], "nickname", None + ) or titlecase(camera.replace("_", " ")) current_time = datetime.datetime.now().timestamp() if self._within_cooldown(camera): @@ -375,7 +378,7 @@ class WebPushClient(Communicator): if state == "genai" and payload["after"]["data"]["metadata"]: message = payload["after"]["data"]["metadata"]["scene"] else: - message = f"Detected on {titlecase(camera.replace('_', ' '))}" + message = f"Detected on {camera_name}" if ended: logger.debug( @@ -406,6 +409,9 @@ class WebPushClient(Communicator): return camera: str = payload["camera"] + camera_name: str = getattr( + self.config.cameras[camera], "nickname", None + ) or titlecase(camera.replace("_", " ")) current_time = datetime.datetime.now().timestamp() if self._within_cooldown(camera): @@ -421,14 +427,16 @@ class WebPushClient(Communicator): name = payload["name"] score = payload["score"] - title = f"{name.replace('_', ' ')} triggered on {titlecase(camera.replace('_', ' '))}" - message = f"{titlecase(trigger_type)} trigger fired for {titlecase(camera.replace('_', ' '))} with score {score:.2f}" + title = f"{name.replace('_', ' ')} triggered on {camera_name}" + message = f"{titlecase(trigger_type)} trigger fired for {camera_name} with score {score:.2f}" image = f"clips/triggers/{camera}/{event_id}.webp" direct_url = f"/explore?event_id={event_id}" ttl = 0 - logger.debug(f"Sending push notification for {camera}, trigger name {name}") + logger.debug( + f"Sending push notification for {camera_name}, trigger name {name}" + ) for user in self.web_pushers: self.send_push_notification( diff --git a/frigate/config/camera/camera.py b/frigate/config/camera/camera.py index a3c9733ff..4290c8bf2 100644 --- a/frigate/config/camera/camera.py +++ b/frigate/config/camera/camera.py @@ -2,7 +2,7 @@ import os from enum import Enum from typing import Optional -from pydantic import Field, PrivateAttr +from pydantic import Field, PrivateAttr, model_validator from frigate.const import CACHE_DIR, CACHE_SEGMENT_FORMAT, REGEX_CAMERA_NAME from frigate.ffmpeg_presets import ( @@ -51,6 +51,16 @@ class CameraTypeEnum(str, Enum): class CameraConfig(FrigateBaseModel): name: Optional[str] = Field(None, title="Camera name.", pattern=REGEX_CAMERA_NAME) + + nickname: Optional[str] = Field(None, title="Camera nickname. Only for display.") + + @model_validator(mode="before") + @classmethod + def handle_nickname(cls, values): + if isinstance(values, dict) and "nickname" in values: + pass + return values + enabled: bool = Field(default=True, title="Enable camera.") # Options with global fallback diff --git a/frigate/storage.py b/frigate/storage.py index 1c4650271..ddd1eceed 100644 --- a/frigate/storage.py +++ b/frigate/storage.py @@ -77,7 +77,10 @@ class StorageMaintainer(threading.Thread): .scalar() ) - usages[camera] = { + camera_key = ( + getattr(self.config.cameras[camera], "nickname", None) or camera + ) + usages[camera_key] = { "usage": camera_storage, "bandwidth": self.camera_storage_stats.get(camera, {}).get( "bandwidth", 0 diff --git a/web/public/locales/en/components/camera.json b/web/public/locales/en/components/camera.json index 1ac9cc513..864efa6c4 100644 --- a/web/public/locales/en/components/camera.json +++ b/web/public/locales/en/components/camera.json @@ -27,6 +27,7 @@ "icon": "Icon", "success": "Camera group ({{name}}) has been saved.", "camera": { + "birdseye": "Birdseye", "setting": { "label": "Camera Streaming Settings", "title": "{{cameraName}} Streaming Settings", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index ed00f0ad6..e7c06b133 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -17,6 +17,7 @@ "cameras": "Camera Settings", "masksAndZones": "Masks / Zones", "motionTuner": "Motion Tuner", + "triggers": "Triggers", "debug": "Debug", "users": "Users", "notifications": "Notifications", @@ -192,7 +193,7 @@ "description": "Configure camera settings including stream inputs and roles.", "name": "Camera Name", "nameRequired": "Camera name is required", - "nameInvalid": "Camera name must contain only letters, numbers, underscores, or hyphens", + "nameLength": "Camera name must be less than 24 characters.", "namePlaceholder": "e.g., front_door", "enabled": "Enabled", "ffmpeg": { @@ -408,6 +409,7 @@ "title": "Debug", "detectorDesc": "Frigate uses your detectors ({{detectors}}) to detect objects in your camera's video stream.", "desc": "Debugging view shows a real-time view of tracked objects and their statistics. The object list shows a time-delayed summary of detected objects.", + "openCameraWebUI": "Open {{camera}}'s Web UI", "debugging": "Debugging", "objectList": "Object List", "noObjects": "No objects", diff --git a/web/src/components/camera/CameraNameLabel.tsx b/web/src/components/camera/CameraNameLabel.tsx new file mode 100644 index 000000000..c05502b32 --- /dev/null +++ b/web/src/components/camera/CameraNameLabel.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { useCameraNickname } from "@/hooks/use-camera-nickname"; +import { CameraConfig } from "@/types/frigateConfig"; + +interface CameraNameLabelProps + extends React.ComponentPropsWithoutRef { + camera?: string | CameraConfig; +} + +const CameraNameLabel = React.forwardRef< + React.ElementRef, + CameraNameLabelProps +>(({ className, camera, ...props }, ref) => { + const displayName = useCameraNickname(camera); + return ( + + {displayName} + + ); +}); +CameraNameLabel.displayName = LabelPrimitive.Root.displayName; + +export { CameraNameLabel }; diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 6d9ea7856..6e20687cc 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -71,12 +71,12 @@ import { MobilePageTitle, } from "../mobile/MobilePage"; -import { Label } from "../ui/label"; import { Switch } from "../ui/switch"; import { CameraStreamingDialog } from "../settings/CameraStreamingDialog"; import { DialogTrigger } from "@radix-ui/react-dialog"; import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { Trans, useTranslation } from "react-i18next"; +import { CameraNameLabel } from "../camera/CameraNameLabel"; type CameraGroupSelectorProps = { className?: string; @@ -846,12 +846,11 @@ export function CameraGroupEdit({ ].map((camera) => (
- + camera={camera} + />
{camera !== "birdseye" && ( diff --git a/web/src/components/filter/CamerasFilterButton.tsx b/web/src/components/filter/CamerasFilterButton.tsx index 247555a0a..93b8a8651 100644 --- a/web/src/components/filter/CamerasFilterButton.tsx +++ b/web/src/components/filter/CamerasFilterButton.tsx @@ -189,7 +189,8 @@ export function CamerasFilterContent({ void; }; export default function FilterSwitch({ label, disabled = false, isChecked, + isCameraName = false, onCheckedChange, }: FilterSwitchProps) { return (
- + {isCameraName ? ( + + ) : ( + + )} {t("filter.label." + filterType)}:{" "} - {filterType === "labels" - ? getTranslatedLabel(value) - : value.replaceAll("_", " ")} + {filterType === "labels" ? ( + getTranslatedLabel(value) + ) : filterType === "cameras" ? ( + + ) : ( + value.replaceAll("_", " ") + )}
- {review.camera.replaceAll("_", " ")} +
diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index b7b495ef8..d6724404d 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -79,6 +79,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin"; import FaceSelectionDialog from "../FaceSelectionDialog"; import { getTranslatedLabel } from "@/utils/i18n"; import { CgTranscript } from "react-icons/cg"; +import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; const SEARCH_TABS = [ "details", @@ -864,7 +865,7 @@ function ObjectDetailsTab({
{t("details.camera")}
- {search.camera.replaceAll("_", " ")} +
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 3d8df2b34..26834973f 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -24,6 +24,7 @@ import { baseUrl } from "@/api/baseUrl"; import { PlayerStats } from "./PlayerStats"; import { LuVideoOff } from "react-icons/lu"; import { Trans, useTranslation } from "react-i18next"; +import { useCameraNickname } from "@/hooks/use-camera-nickname"; type LivePlayerProps = { cameraRef?: (ref: HTMLDivElement | null) => void; @@ -76,6 +77,7 @@ export default function LivePlayer({ const internalContainerRef = useRef(null); + const cameraName = useCameraNickname(cameraConfig); // stats const [stats, setStats] = useState({ @@ -412,7 +414,7 @@ export default function LivePlayer({ streamOffline.desc @@ -444,7 +446,7 @@ export default function LivePlayer({ - {cameraConfig.name.replaceAll("_", " ")} + {cameraName} )}
diff --git a/web/src/components/player/PreviewPlayer.tsx b/web/src/components/player/PreviewPlayer.tsx index 4e807d771..13cf3d576 100644 --- a/web/src/components/player/PreviewPlayer.tsx +++ b/web/src/components/player/PreviewPlayer.tsx @@ -21,6 +21,7 @@ import { usePreviewForTimeRange, } from "@/hooks/use-camera-previews"; import { useTranslation } from "react-i18next"; +import { useCameraNickname } from "@/hooks/use-camera-nickname"; type PreviewPlayerProps = { previewRef?: (ref: HTMLDivElement | null) => void; @@ -148,6 +149,7 @@ function PreviewVideoPlayer({ const { t } = useTranslation(["components/player"]); const { data: config } = useSWR("config"); + const cameraName = useCameraNickname(camera); // controlling playback const previewRef = useRef(null); @@ -342,7 +344,7 @@ function PreviewVideoPlayer({ )} {cameraPreviews && !currentPreview && (
- {t("noPreviewFoundFor", { camera: camera.replaceAll("_", " ") })} + {t("noPreviewFoundFor", { camera: cameraName })}
)} {firstLoad && } @@ -464,6 +466,7 @@ function PreviewFramesPlayer({ }: PreviewFramesPlayerProps) { const { t } = useTranslation(["components/player"]); + const cameraName = useCameraNickname(camera); // frames data const { data: previewFrames } = useSWR( @@ -564,7 +567,7 @@ function PreviewFramesPlayer({ /> {previewFrames?.length === 0 && (
- {t("noPreviewFoundFor", { cameraName: camera.replaceAll("_", " ") })} + {t("noPreviewFoundFor", { cameraName: cameraName })}
)} {firstLoad && } diff --git a/web/src/components/settings/CameraEditForm.tsx b/web/src/components/settings/CameraEditForm.tsx index eb731b2b3..86c7d1e93 100644 --- a/web/src/components/settings/CameraEditForm.tsx +++ b/web/src/components/settings/CameraEditForm.tsx @@ -30,6 +30,12 @@ type ConfigSetBody = { config_data: any; update_topic?: string; }; +const generateFixedHash = (name: string): string => { + const encoded = encodeURIComponent(name); + const base64 = btoa(encoded); + const cleanHash = base64.replace(/[^a-zA-Z0-9]/g, "").substring(0, 8); + return `cam_${cleanHash.toLowerCase()}`; +}; const RoleEnum = z.enum(["audio", "detect", "record"]); type Role = z.infer; @@ -54,10 +60,7 @@ export default function CameraEditForm({ z.object({ cameraName: z .string() - .min(1, { message: t("camera.cameraConfig.nameRequired") }) - .regex(/^[a-zA-Z0-9_-]+$/, { - message: t("camera.cameraConfig.nameInvalid"), - }), + .min(1, { message: t("camera.cameraConfig.nameRequired") }), enabled: z.boolean(), ffmpeg: z.object({ inputs: z @@ -101,26 +104,37 @@ export default function CameraEditForm({ type FormValues = z.infer; - // Determine available roles for default values - const usedRoles = useMemo(() => { - const roles = new Set(); - if (cameraName && config?.cameras[cameraName]) { - const camera = config.cameras[cameraName]; - camera.ffmpeg?.inputs?.forEach((input) => { - input.roles.forEach((role) => roles.add(role as Role)); - }); + const cameraInfo = useMemo(() => { + if (!cameraName || !config?.cameras[cameraName]) { + return { + nickname: undefined, + name: cameraName || "", + roles: new Set(), + }; } - return roles; + + const camera = config.cameras[cameraName]; + const roles = new Set(); + + camera.ffmpeg?.inputs?.forEach((input) => { + input.roles.forEach((role) => roles.add(role as Role)); + }); + + return { + nickname: camera?.nickname || cameraName, + name: cameraName, + roles, + }; }, [cameraName, config]); const defaultValues: FormValues = { - cameraName: cameraName || "", + cameraName: cameraInfo?.nickname || cameraName || "", enabled: true, ffmpeg: { inputs: [ { path: "", - roles: usedRoles.has("detect") ? [] : ["detect"], + roles: cameraInfo.roles.has("detect") ? [] : ["detect"], }, ], }, @@ -154,10 +168,19 @@ export default function CameraEditForm({ const saveCameraConfig = (values: FormValues) => { setIsLoading(true); + let finalCameraName = values.cameraName; + let nickname: string | undefined = undefined; + const isValidName = /^[a-zA-Z0-9_-]+$/.test(values.cameraName); + if (!isValidName) { + finalCameraName = generateFixedHash(finalCameraName); + nickname = values.cameraName; + } + const configData: ConfigSetBody["config_data"] = { cameras: { - [values.cameraName]: { + [finalCameraName]: { enabled: values.enabled, + ...(nickname && { nickname }), ffmpeg: { inputs: values.ffmpeg.inputs.map((input) => ({ path: input.path, @@ -175,7 +198,7 @@ export default function CameraEditForm({ // Add update_topic for new cameras if (!cameraName) { - requestBody.update_topic = `config/cameras/${values.cameraName}/add`; + requestBody.update_topic = `config/cameras/${finalCameraName}/add`; } axios @@ -209,7 +232,11 @@ export default function CameraEditForm({ }; const onSubmit = (values: FormValues) => { - if (cameraName && values.cameraName !== cameraName) { + if ( + cameraName && + values.cameraName !== cameraName && + values.cameraName !== cameraInfo?.nickname + ) { // If camera name changed, delete old camera config const deleteRequestBody: ConfigSetBody = { requires_restart: 1, diff --git a/web/src/components/settings/CameraStreamingDialog.tsx b/web/src/components/settings/CameraStreamingDialog.tsx index 0e39af83a..c49e186be 100644 --- a/web/src/components/settings/CameraStreamingDialog.tsx +++ b/web/src/components/settings/CameraStreamingDialog.tsx @@ -33,6 +33,7 @@ import { Link } from "react-router-dom"; import { LiveStreamMetadata } from "@/types/live"; import { Trans, useTranslation } from "react-i18next"; import { useDocDomain } from "@/hooks/use-doc-domain"; +import { useCameraNickname } from "@/hooks/use-camera-nickname"; type CameraStreamingDialogProps = { camera: string; @@ -56,6 +57,8 @@ export function CameraStreamingDialog({ const { getLocaleDocUrl } = useDocDomain(); const { data: config } = useSWR("config"); + const cameraName = useCameraNickname(camera); + const [isLoading, setIsLoading] = useState(false); const [streamName, setStreamName] = useState( @@ -190,7 +193,7 @@ export function CameraStreamingDialog({ {t("group.camera.setting.title", { - cameraName: camera.replaceAll("_", " "), + cameraName: cameraName, })} diff --git a/web/src/hooks/use-camera-nickname.ts b/web/src/hooks/use-camera-nickname.ts new file mode 100644 index 000000000..5f5fe9cf1 --- /dev/null +++ b/web/src/hooks/use-camera-nickname.ts @@ -0,0 +1,29 @@ +import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; +import { useMemo } from "react"; +import useSWR from "swr"; + +export function resolveCameraName( + config: FrigateConfig | undefined, + cameraId: string | CameraConfig | undefined, +) { + if (typeof cameraId === "object" && cameraId !== null) { + const camera = cameraId as CameraConfig; + return camera?.nickname || camera?.name.replaceAll("_", " "); + } else { + const camera = config?.cameras?.[String(cameraId)]; + return camera?.nickname || String(cameraId).replaceAll("_", " "); + } +} + +export function useCameraNickname( + cameraId: string | CameraConfig | undefined, +): string { + const { data: config } = useSWR("config"); + + const name = useMemo( + () => resolveCameraName(config, cameraId), + [config, cameraId], + ); + + return name; +} diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index 5141742c0..48d9b6f2a 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -8,7 +8,7 @@ import { FrigateStats, PotentialProblem } from "@/types/stats"; import { useMemo } from "react"; import useSWR from "swr"; import useDeepMemo from "./use-deep-memo"; -import { capitalizeFirstLetter } from "@/utils/stringUtil"; +import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil"; import { useFrigateStats } from "@/api/ws"; import { useTranslation } from "react-i18next"; @@ -61,10 +61,11 @@ export default function useStats(stats: FrigateStats | undefined) { return; } + const cameraName = config.cameras?.[name]?.nickname ?? name; if (config.cameras[name].enabled && cam["camera_fps"] == 0) { problems.push({ text: t("stats.cameraIsOffline", { - camera: capitalizeFirstLetter(name.replaceAll("_", " ")), + camera: capitalizeFirstLetter(capitalizeAll(cameraName)), }), color: "text-danger", relevantLink: "logs", @@ -81,10 +82,11 @@ export default function useStats(stats: FrigateStats | undefined) { memoizedStats["cpu_usages"][cam["pid"]]?.cpu_average, ); + const cameraName = config?.cameras?.[name]?.nickname ?? name; if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) { problems.push({ text: t("stats.ffmpegHighCpuUsage", { - camera: capitalizeFirstLetter(name.replaceAll("_", " ")), + camera: capitalizeFirstLetter(capitalizeAll(cameraName)), ffmpegAvg, }), color: "text-danger", @@ -95,7 +97,7 @@ export default function useStats(stats: FrigateStats | undefined) { if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) { problems.push({ text: t("stats.detectHighCpuUsage", { - camera: capitalizeFirstLetter(name.replaceAll("_", " ")), + camera: capitalizeFirstLetter(capitalizeAll(cameraName)), detectAvg, }), color: "text-danger", diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 7e68fbd3e..47d2d6b35 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -46,6 +46,7 @@ import { isPWA } from "@/utils/isPWA"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { useTranslation } from "react-i18next"; import TriggerView from "@/views/settings/TriggerView"; +import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; const allSettingsViews = [ "ui", @@ -351,9 +352,11 @@ function CameraSelectButton({ >
- {selectedCamera == undefined - ? t("cameraSetting.noCamera") - : selectedCamera.replaceAll("_", " ")} + {selectedCamera == undefined ? ( + t("cameraSetting.noCamera") + ) : ( + + )}
); @@ -376,7 +379,8 @@ function CameraSelectButton({ { if (isChecked && (isEnabled || isCameraSettingsPage)) { setSelectedCamera(item.name); diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 22a35bdd9..eb806d7b5 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -33,6 +33,7 @@ export type SearchModel = "jinav1" | "jinav2"; export type SearchModelSize = "small" | "large"; export interface CameraConfig { + nickname: string; audio: { enabled: boolean; enabled_in_config: boolean; diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 582a32f4f..12783eb6c 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -64,6 +64,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; type RecordingViewProps = { startCamera: string; @@ -719,7 +720,7 @@ export function RecordingView({
- {cam.replaceAll("_", " ")} + ); diff --git a/web/src/views/settings/CameraSettingsView.tsx b/web/src/views/settings/CameraSettingsView.tsx index 41bb15bef..8894a5b8f 100644 --- a/web/src/views/settings/CameraSettingsView.tsx +++ b/web/src/views/settings/CameraSettingsView.tsx @@ -49,6 +49,8 @@ import { } from "@/components/ui/select"; import { IoMdArrowRoundBack } from "react-icons/io"; import { isDesktop } from "react-device-detect"; +import { useCameraNickname } from "@/hooks/use-camera-nickname"; +import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; type CameraSettingsViewProps = { selectedCamera: string; @@ -96,6 +98,8 @@ export default function CameraSettingsView({ return []; }, [config]); + const selectCameraName = useCameraNickname(selectedCamera); + // zones and labels const zones = useMemo(() => { @@ -337,11 +341,13 @@ export default function CameraSettingsView({ - {cameras.map((camera) => ( - - {capitalizeFirstLetter(camera.replaceAll("_", " "))} - - ))} + {cameras.map((camera) => { + return ( + + + + ); + })}
@@ -612,18 +618,14 @@ export default function CameraSettingsView({ ), ) .join(", "), - cameraName: capitalizeFirstLetter( - cameraConfig?.name ?? "", - ).replaceAll("_", " "), + cameraName: selectCameraName, }, ) : t( "camera.reviewClassification.objectAlertsTips", { alertsLabels, - cameraName: capitalizeFirstLetter( - cameraConfig?.name ?? "", - ).replaceAll("_", " "), + cameraName: selectCameraName, }, )} @@ -735,9 +737,7 @@ export default function CameraSettingsView({ ), ) .join(", "), - cameraName: capitalizeFirstLetter( - cameraConfig?.name ?? "", - ).replaceAll("_", " "), + cameraName: selectCameraName, }} ns="views/settings" /> @@ -754,9 +754,7 @@ export default function CameraSettingsView({ ), ) .join(", "), - cameraName: capitalizeFirstLetter( - cameraConfig?.name ?? "", - ).replaceAll("_", " "), + cameraName: selectCameraName, }} ns="views/settings" /> @@ -766,9 +764,7 @@ export default function CameraSettingsView({ i18nKey="camera.reviewClassification.objectDetectionsTips" values={{ detectionsLabels, - cameraName: capitalizeFirstLetter( - cameraConfig?.name ?? "", - ).replaceAll("_", " "), + cameraName: selectCameraName, }} ns="views/settings" /> diff --git a/web/src/views/settings/FrigatePlusSettingsView.tsx b/web/src/views/settings/FrigatePlusSettingsView.tsx index bcf7434e6..39d48f02c 100644 --- a/web/src/views/settings/FrigatePlusSettingsView.tsx +++ b/web/src/views/settings/FrigatePlusSettingsView.tsx @@ -23,6 +23,7 @@ import { SelectTrigger, } from "@/components/ui/select"; import { useDocDomain } from "@/hooks/use-doc-domain"; +import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; type FrigatePlusModel = { id: string; @@ -488,7 +489,9 @@ export default function FrigatePlusSettingsView({ key={name} className="border-b border-secondary" > - {name} + + + {camera.snapshots.enabled ? ( diff --git a/web/src/views/settings/NotificationsSettingsView.tsx b/web/src/views/settings/NotificationsSettingsView.tsx index e5027cd8e..3b2c89f05 100644 --- a/web/src/views/settings/NotificationsSettingsView.tsx +++ b/web/src/views/settings/NotificationsSettingsView.tsx @@ -11,7 +11,6 @@ import { } from "@/components/ui/form"; import Heading from "@/components/ui/heading"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Toaster } from "@/components/ui/sonner"; import { StatusBarMessagesContext } from "@/context/statusbar-provider"; @@ -46,6 +45,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Trans, useTranslation } from "react-i18next"; import { useDateLocale } from "@/hooks/use-date-locale"; import { useDocDomain } from "@/hooks/use-doc-domain"; +import { CameraNameLabel } from "@/components/camera/CameraNameLabel"; const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js"; @@ -464,7 +464,8 @@ export default function NotificationView({ {allCameras?.map((camera) => ( { setChangedValue(true); @@ -697,12 +698,11 @@ export function CameraNotificationSwitch({ )}
- + camera={camera} + /> {!isSuspended ? (
diff --git a/web/src/views/settings/ObjectSettingsView.tsx b/web/src/views/settings/ObjectSettingsView.tsx index e14bc911f..6c6363f9d 100644 --- a/web/src/views/settings/ObjectSettingsView.tsx +++ b/web/src/views/settings/ObjectSettingsView.tsx @@ -30,6 +30,7 @@ import { isDesktop } from "react-device-detect"; import { Trans, useTranslation } from "react-i18next"; import { useDocDomain } from "@/hooks/use-doc-domain"; import { getTranslatedLabel } from "@/utils/i18n"; +import { useCameraNickname } from "@/hooks/use-camera-nickname"; import { AudioLevelGraph } from "@/components/audio/AudioLevelGraph"; import { useWs } from "@/api/ws"; @@ -128,6 +129,8 @@ export default function ObjectSettingsView({ } }, [config, selectedCamera]); + const cameraName = useCameraNickname(cameraConfig); + const { objects, audio_detections } = useCameraActivity( cameraConfig ?? ({} as CameraConfig), ); @@ -186,7 +189,9 @@ export default function ObjectSettingsView({ rel="noopener noreferrer" className="inline" > - Open {capitalizeFirstLetter(cameraConfig.name)}'s Web UI + {t("debug.openCameraWebUI", { + camera: cameraName, + })}
diff --git a/web/src/views/settings/TriggerView.tsx b/web/src/views/settings/TriggerView.tsx index d84359f09..61270484f 100644 --- a/web/src/views/settings/TriggerView.tsx +++ b/web/src/views/settings/TriggerView.tsx @@ -23,6 +23,7 @@ import { cn } from "@/lib/utils"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { Link } from "react-router-dom"; import { useTriggers } from "@/api/ws"; +import { useCameraNickname } from "@/hooks/use-camera-nickname"; type ConfigSetBody = { requires_restart: number; @@ -78,6 +79,7 @@ export default function TriggerView({ const [triggeredTrigger, setTriggeredTrigger] = useState(); const [isLoading, setIsLoading] = useState(false); + const cameraName = useCameraNickname(selectedCamera); const triggers = useMemo(() => { if ( !config || @@ -390,7 +392,9 @@ export default function TriggerView({ {t("triggers.management.title")}

- {t("triggers.management.desc", { camera: selectedCamera })} + {t("triggers.management.desc", { + camera: cameraName, + })}