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.
This commit is contained in:
GuoQing Liu 2025-08-27 01:15:01 +08:00 committed by GitHub
parent 195f705616
commit d3af748366
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 276 additions and 99 deletions

View File

@ -334,6 +334,9 @@ class WebPushClient(Communicator):
return return
camera: str = payload["after"]["camera"] 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() current_time = datetime.datetime.now().timestamp()
if self._within_cooldown(camera): if self._within_cooldown(camera):
@ -375,7 +378,7 @@ class WebPushClient(Communicator):
if state == "genai" and payload["after"]["data"]["metadata"]: if state == "genai" and payload["after"]["data"]["metadata"]:
message = payload["after"]["data"]["metadata"]["scene"] message = payload["after"]["data"]["metadata"]["scene"]
else: else:
message = f"Detected on {titlecase(camera.replace('_', ' '))}" message = f"Detected on {camera_name}"
if ended: if ended:
logger.debug( logger.debug(
@ -406,6 +409,9 @@ class WebPushClient(Communicator):
return return
camera: str = payload["camera"] camera: str = payload["camera"]
camera_name: str = getattr(
self.config.cameras[camera], "nickname", None
) or titlecase(camera.replace("_", " "))
current_time = datetime.datetime.now().timestamp() current_time = datetime.datetime.now().timestamp()
if self._within_cooldown(camera): if self._within_cooldown(camera):
@ -421,14 +427,16 @@ class WebPushClient(Communicator):
name = payload["name"] name = payload["name"]
score = payload["score"] score = payload["score"]
title = f"{name.replace('_', ' ')} triggered on {titlecase(camera.replace('_', ' '))}" title = f"{name.replace('_', ' ')} triggered on {camera_name}"
message = f"{titlecase(trigger_type)} trigger fired for {titlecase(camera.replace('_', ' '))} with score {score:.2f}" message = f"{titlecase(trigger_type)} trigger fired for {camera_name} with score {score:.2f}"
image = f"clips/triggers/{camera}/{event_id}.webp" image = f"clips/triggers/{camera}/{event_id}.webp"
direct_url = f"/explore?event_id={event_id}" direct_url = f"/explore?event_id={event_id}"
ttl = 0 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: for user in self.web_pushers:
self.send_push_notification( self.send_push_notification(

View File

@ -2,7 +2,7 @@ import os
from enum import Enum from enum import Enum
from typing import Optional 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.const import CACHE_DIR, CACHE_SEGMENT_FORMAT, REGEX_CAMERA_NAME
from frigate.ffmpeg_presets import ( from frigate.ffmpeg_presets import (
@ -51,6 +51,16 @@ class CameraTypeEnum(str, Enum):
class CameraConfig(FrigateBaseModel): class CameraConfig(FrigateBaseModel):
name: Optional[str] = Field(None, title="Camera name.", pattern=REGEX_CAMERA_NAME) 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.") enabled: bool = Field(default=True, title="Enable camera.")
# Options with global fallback # Options with global fallback

View File

@ -77,7 +77,10 @@ class StorageMaintainer(threading.Thread):
.scalar() .scalar()
) )
usages[camera] = { camera_key = (
getattr(self.config.cameras[camera], "nickname", None) or camera
)
usages[camera_key] = {
"usage": camera_storage, "usage": camera_storage,
"bandwidth": self.camera_storage_stats.get(camera, {}).get( "bandwidth": self.camera_storage_stats.get(camera, {}).get(
"bandwidth", 0 "bandwidth", 0

View File

@ -27,6 +27,7 @@
"icon": "Icon", "icon": "Icon",
"success": "Camera group ({{name}}) has been saved.", "success": "Camera group ({{name}}) has been saved.",
"camera": { "camera": {
"birdseye": "Birdseye",
"setting": { "setting": {
"label": "Camera Streaming Settings", "label": "Camera Streaming Settings",
"title": "{{cameraName}} Streaming Settings", "title": "{{cameraName}} Streaming Settings",

View File

@ -17,6 +17,7 @@
"cameras": "Camera Settings", "cameras": "Camera Settings",
"masksAndZones": "Masks / Zones", "masksAndZones": "Masks / Zones",
"motionTuner": "Motion Tuner", "motionTuner": "Motion Tuner",
"triggers": "Triggers",
"debug": "Debug", "debug": "Debug",
"users": "Users", "users": "Users",
"notifications": "Notifications", "notifications": "Notifications",
@ -192,7 +193,7 @@
"description": "Configure camera settings including stream inputs and roles.", "description": "Configure camera settings including stream inputs and roles.",
"name": "Camera Name", "name": "Camera Name",
"nameRequired": "Camera name is required", "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", "namePlaceholder": "e.g., front_door",
"enabled": "Enabled", "enabled": "Enabled",
"ffmpeg": { "ffmpeg": {
@ -408,6 +409,7 @@
"title": "Debug", "title": "Debug",
"detectorDesc": "Frigate uses your detectors ({{detectors}}) to detect objects in your camera's video stream.", "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.", "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", "debugging": "Debugging",
"objectList": "Object List", "objectList": "Object List",
"noObjects": "No objects", "noObjects": "No objects",

View File

@ -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<typeof LabelPrimitive.Root> {
camera?: string | CameraConfig;
}
const CameraNameLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
CameraNameLabelProps
>(({ className, camera, ...props }, ref) => {
const displayName = useCameraNickname(camera);
return (
<LabelPrimitive.Root ref={ref} className={className} {...props}>
{displayName}
</LabelPrimitive.Root>
);
});
CameraNameLabel.displayName = LabelPrimitive.Root.displayName;
export { CameraNameLabel };

View File

@ -71,12 +71,12 @@ import {
MobilePageTitle, MobilePageTitle,
} from "../mobile/MobilePage"; } from "../mobile/MobilePage";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { CameraStreamingDialog } from "../settings/CameraStreamingDialog"; import { CameraStreamingDialog } from "../settings/CameraStreamingDialog";
import { DialogTrigger } from "@radix-ui/react-dialog"; import { DialogTrigger } from "@radix-ui/react-dialog";
import { useStreamingSettings } from "@/context/streaming-settings-provider"; import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type CameraGroupSelectorProps = { type CameraGroupSelectorProps = {
className?: string; className?: string;
@ -846,12 +846,11 @@ export function CameraGroupEdit({
].map((camera) => ( ].map((camera) => (
<FormControl key={camera}> <FormControl key={camera}>
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
<Label <CameraNameLabel
className="mx-2 w-full cursor-pointer text-primary smart-capitalize" className="mx-2 w-full cursor-pointer text-primary smart-capitalize"
htmlFor={camera.replaceAll("_", " ")} htmlFor={camera.replaceAll("_", " ")}
> camera={camera}
{camera.replaceAll("_", " ")} />
</Label>
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
{camera !== "birdseye" && ( {camera !== "birdseye" && (

View File

@ -189,7 +189,8 @@ export function CamerasFilterContent({
<FilterSwitch <FilterSwitch
key={item} key={item}
isChecked={currentCameras?.includes(item) ?? false} isChecked={currentCameras?.includes(item) ?? false}
label={item.replaceAll("_", " ")} label={item}
isCameraName={true}
disabled={ disabled={
mainCamera !== undefined && mainCamera !== undefined &&
currentCameras !== undefined && currentCameras !== undefined &&

View File

@ -1,26 +1,37 @@
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type FilterSwitchProps = { type FilterSwitchProps = {
label: string; label: string;
disabled?: boolean; disabled?: boolean;
isChecked: boolean; isChecked: boolean;
isCameraName?: boolean;
onCheckedChange: (checked: boolean) => void; onCheckedChange: (checked: boolean) => void;
}; };
export default function FilterSwitch({ export default function FilterSwitch({
label, label,
disabled = false, disabled = false,
isChecked, isChecked,
isCameraName = false,
onCheckedChange, onCheckedChange,
}: FilterSwitchProps) { }: FilterSwitchProps) {
return ( return (
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
<Label {isCameraName ? (
className={`mx-2 w-full cursor-pointer text-primary smart-capitalize ${disabled ? "text-secondary-foreground" : ""}`} <CameraNameLabel
htmlFor={label} className={`mx-2 w-full cursor-pointer text-sm font-medium leading-none text-primary smart-capitalize peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${disabled ? "text-secondary-foreground" : ""}`}
> htmlFor={label}
{label} camera={label}
</Label> />
) : (
<Label
className={`mx-2 w-full cursor-pointer text-primary smart-capitalize ${disabled ? "text-secondary-foreground" : ""}`}
htmlFor={label}
>
{label}
</Label>
)}
<Switch <Switch
id={label} id={label}
disabled={disabled} disabled={disabled}

View File

@ -53,6 +53,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { MdImageSearch } from "react-icons/md"; import { MdImageSearch } from "react-icons/md";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type InputWithTagsProps = { type InputWithTagsProps = {
inputFocused: boolean; inputFocused: boolean;
@ -826,9 +827,13 @@ export default function InputWithTags({
className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800 smart-capitalize" className="inline-flex items-center whitespace-nowrap rounded-full bg-green-100 px-2 py-0.5 text-sm text-green-800 smart-capitalize"
> >
{t("filter.label." + filterType)}:{" "} {t("filter.label." + filterType)}:{" "}
{filterType === "labels" {filterType === "labels" ? (
? getTranslatedLabel(value) getTranslatedLabel(value)
: value.replaceAll("_", " ")} ) : filterType === "cameras" ? (
<CameraNameLabel camera={value} />
) : (
value.replaceAll("_", " ")
)}
<button <button
onClick={() => onClick={() =>
removeFilter(filterType as FilterType, value) removeFilter(filterType as FilterType, value)
@ -923,13 +928,27 @@ export default function InputWithTags({
onSelect={() => handleSuggestionClick(suggestion)} onSelect={() => handleSuggestionClick(suggestion)}
> >
{i18n.language === "en" ? ( {i18n.language === "en" ? (
suggestion currentFilterType && currentFilterType === "cameras" ? (
<>
{suggestion} {" ("}{" "}
<CameraNameLabel camera={suggestion} />
{")"}
</>
) : (
suggestion
)
) : ( ) : (
<> <>
{suggestion} {" ("} {suggestion} {" ("}
{currentFilterType {currentFilterType ? (
? formatFilterValues(currentFilterType, suggestion) currentFilterType === "cameras" ? (
: t("filter.label." + suggestion)} <CameraNameLabel camera={suggestion} />
) : (
formatFilterValues(currentFilterType, suggestion)
)
) : (
t("filter.label." + suggestion)
)}
{")"} {")"}
</> </>
)} )}

View File

@ -47,6 +47,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale"; import { useDateLocale } from "@/hooks/use-date-locale";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type LiveContextMenuProps = { type LiveContextMenuProps = {
className?: string; className?: string;
@ -271,7 +272,7 @@ export default function LiveContextMenu({
<ContextMenuContent> <ContextMenuContent>
<div className="flex flex-col items-start gap-1 py-1 pl-2"> <div className="flex flex-col items-start gap-1 py-1 pl-2">
<div className="text-md text-primary-variant smart-capitalize"> <div className="text-md text-primary-variant smart-capitalize">
{camera.replaceAll("_", " ")} <CameraNameLabel camera={camera} />
</div> </div>
{preferredLiveMode == "jsmpeg" && isRestreamed && ( {preferredLiveMode == "jsmpeg" && isRestreamed && (
<div className="flex flex-row items-center gap-1"> <div className="flex flex-row items-center gap-1">

View File

@ -16,6 +16,7 @@ import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { Toaster } from "../ui/sonner"; import { Toaster } from "../ui/sonner";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useCameraNickname } from "@/hooks/use-camera-nickname";
type CameraInfoDialogProps = { type CameraInfoDialogProps = {
camera: CameraConfig; camera: CameraConfig;
@ -74,6 +75,8 @@ export default function CameraInfoDialog({
return b === 0 ? a : gcd(b, a % b); return b === 0 ? a : gcd(b, a % b);
} }
const cameraName = useCameraNickname(camera);
return ( return (
<> <>
<Toaster position="top-center" /> <Toaster position="top-center" />
@ -85,7 +88,7 @@ export default function CameraInfoDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="smart-capitalize"> <DialogTitle className="smart-capitalize">
{t("cameras.info.cameraProbeInfo", { {t("cameras.info.cameraProbeInfo", {
camera: camera.name.replaceAll("_", " "), camera: cameraName,
})} })}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>

View File

@ -37,6 +37,7 @@ import ImagePicker from "@/components/overlay/ImagePicker";
import { Trigger, TriggerAction, TriggerType } from "@/types/trigger"; import { Trigger, TriggerAction, TriggerType } from "@/types/trigger";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "../ui/textarea"; import { Textarea } from "../ui/textarea";
import { useCameraNickname } from "@/hooks/use-camera-nickname";
type CreateTriggerDialogProps = { type CreateTriggerDialogProps = {
show: boolean; show: boolean;
@ -161,6 +162,8 @@ export default function CreateTriggerDialog({
onCancel(); onCancel();
}; };
const cameraName = useCameraNickname(selectedCamera);
return ( return (
<Dialog open={show} onOpenChange={onCancel}> <Dialog open={show} onOpenChange={onCancel}>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[425px]">
@ -177,7 +180,9 @@ export default function CreateTriggerDialog({
trigger trigger
? "triggers.dialog.editTrigger.desc" ? "triggers.dialog.editTrigger.desc"
: "triggers.dialog.createTrigger.desc", : "triggers.dialog.createTrigger.desc",
{ camera: selectedCamera }, {
camera: cameraName,
},
)} )}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -4,6 +4,7 @@ import { Button } from "../ui/button";
import { FaVideo } from "react-icons/fa"; import { FaVideo } from "react-icons/fa";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type MobileCameraDrawerProps = { type MobileCameraDrawerProps = {
allCameras: string[]; allCameras: string[];
@ -44,7 +45,7 @@ export default function MobileCameraDrawer({
setCameraDrawer(false); setCameraDrawer(false);
}} }}
> >
{cam.replaceAll("_", " ")} <CameraNameLabel camera={cam} />
</div> </div>
))} ))}
</div> </div>

View File

@ -47,6 +47,7 @@ import { LuSearch } from "react-icons/lu";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type ReviewDetailDialogProps = { type ReviewDetailDialogProps = {
review?: ReviewSegment; review?: ReviewSegment;
@ -292,7 +293,7 @@ export default function ReviewDetailDialog({
{t("details.camera")} {t("details.camera")}
</div> </div>
<div className="text-sm smart-capitalize"> <div className="text-sm smart-capitalize">
{review.camera.replaceAll("_", " ")} <CameraNameLabel camera={review.camera} />
</div> </div>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">

View File

@ -79,6 +79,7 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
import FaceSelectionDialog from "../FaceSelectionDialog"; import FaceSelectionDialog from "../FaceSelectionDialog";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { CgTranscript } from "react-icons/cg"; import { CgTranscript } from "react-icons/cg";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
const SEARCH_TABS = [ const SEARCH_TABS = [
"details", "details",
@ -864,7 +865,7 @@ function ObjectDetailsTab({
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="text-sm text-primary/40">{t("details.camera")}</div> <div className="text-sm text-primary/40">{t("details.camera")}</div>
<div className="text-sm smart-capitalize"> <div className="text-sm smart-capitalize">
{search.camera.replaceAll("_", " ")} <CameraNameLabel camera={search.camera} />
</div> </div>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">

View File

@ -24,6 +24,7 @@ import { baseUrl } from "@/api/baseUrl";
import { PlayerStats } from "./PlayerStats"; import { PlayerStats } from "./PlayerStats";
import { LuVideoOff } from "react-icons/lu"; import { LuVideoOff } from "react-icons/lu";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useCameraNickname } from "@/hooks/use-camera-nickname";
type LivePlayerProps = { type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void; cameraRef?: (ref: HTMLDivElement | null) => void;
@ -76,6 +77,7 @@ export default function LivePlayer({
const internalContainerRef = useRef<HTMLDivElement | null>(null); const internalContainerRef = useRef<HTMLDivElement | null>(null);
const cameraName = useCameraNickname(cameraConfig);
// stats // stats
const [stats, setStats] = useState<PlayerStatsType>({ const [stats, setStats] = useState<PlayerStatsType>({
@ -412,7 +414,7 @@ export default function LivePlayer({
<Trans <Trans
ns="components/player" ns="components/player"
values={{ values={{
cameraName: capitalizeFirstLetter(cameraConfig.name), cameraName: cameraName,
}} }}
> >
streamOffline.desc streamOffline.desc
@ -444,7 +446,7 @@ export default function LivePlayer({
<Chip <Chip
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`} className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`}
> >
{cameraConfig.name.replaceAll("_", " ")} {cameraName}
</Chip> </Chip>
)} )}
</div> </div>

View File

@ -21,6 +21,7 @@ import {
usePreviewForTimeRange, usePreviewForTimeRange,
} from "@/hooks/use-camera-previews"; } from "@/hooks/use-camera-previews";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useCameraNickname } from "@/hooks/use-camera-nickname";
type PreviewPlayerProps = { type PreviewPlayerProps = {
previewRef?: (ref: HTMLDivElement | null) => void; previewRef?: (ref: HTMLDivElement | null) => void;
@ -148,6 +149,7 @@ function PreviewVideoPlayer({
const { t } = useTranslation(["components/player"]); const { t } = useTranslation(["components/player"]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const cameraName = useCameraNickname(camera);
// controlling playback // controlling playback
const previewRef = useRef<HTMLVideoElement | null>(null); const previewRef = useRef<HTMLVideoElement | null>(null);
@ -342,7 +344,7 @@ function PreviewVideoPlayer({
)} )}
{cameraPreviews && !currentPreview && ( {cameraPreviews && !currentPreview && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background_alt text-primary dark:bg-black md:rounded-2xl"> <div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background_alt text-primary dark:bg-black md:rounded-2xl">
{t("noPreviewFoundFor", { camera: camera.replaceAll("_", " ") })} {t("noPreviewFoundFor", { camera: cameraName })}
</div> </div>
)} )}
{firstLoad && <Skeleton className="absolute aspect-video size-full" />} {firstLoad && <Skeleton className="absolute aspect-video size-full" />}
@ -464,6 +466,7 @@ function PreviewFramesPlayer({
}: PreviewFramesPlayerProps) { }: PreviewFramesPlayerProps) {
const { t } = useTranslation(["components/player"]); const { t } = useTranslation(["components/player"]);
const cameraName = useCameraNickname(camera);
// frames data // frames data
const { data: previewFrames } = useSWR<string[]>( const { data: previewFrames } = useSWR<string[]>(
@ -564,7 +567,7 @@ function PreviewFramesPlayer({
/> />
{previewFrames?.length === 0 && ( {previewFrames?.length === 0 && (
<div className="-y-translate-1/2 align-center absolute inset-x-0 top-1/2 rounded-lg bg-background_alt text-center text-primary dark:bg-black md:rounded-2xl"> <div className="-y-translate-1/2 align-center absolute inset-x-0 top-1/2 rounded-lg bg-background_alt text-center text-primary dark:bg-black md:rounded-2xl">
{t("noPreviewFoundFor", { cameraName: camera.replaceAll("_", " ") })} {t("noPreviewFoundFor", { cameraName: cameraName })}
</div> </div>
)} )}
{firstLoad && <Skeleton className="absolute aspect-video size-full" />} {firstLoad && <Skeleton className="absolute aspect-video size-full" />}

View File

@ -30,6 +30,12 @@ type ConfigSetBody = {
config_data: any; config_data: any;
update_topic?: string; 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"]); const RoleEnum = z.enum(["audio", "detect", "record"]);
type Role = z.infer<typeof RoleEnum>; type Role = z.infer<typeof RoleEnum>;
@ -54,10 +60,7 @@ export default function CameraEditForm({
z.object({ z.object({
cameraName: z cameraName: z
.string() .string()
.min(1, { message: t("camera.cameraConfig.nameRequired") }) .min(1, { message: t("camera.cameraConfig.nameRequired") }),
.regex(/^[a-zA-Z0-9_-]+$/, {
message: t("camera.cameraConfig.nameInvalid"),
}),
enabled: z.boolean(), enabled: z.boolean(),
ffmpeg: z.object({ ffmpeg: z.object({
inputs: z inputs: z
@ -101,26 +104,37 @@ export default function CameraEditForm({
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
// Determine available roles for default values const cameraInfo = useMemo(() => {
const usedRoles = useMemo(() => { if (!cameraName || !config?.cameras[cameraName]) {
const roles = new Set<Role>(); return {
if (cameraName && config?.cameras[cameraName]) { nickname: undefined,
const camera = config.cameras[cameraName]; name: cameraName || "",
camera.ffmpeg?.inputs?.forEach((input) => { roles: new Set<Role>(),
input.roles.forEach((role) => roles.add(role as Role)); };
});
} }
return roles;
const camera = config.cameras[cameraName];
const roles = new Set<Role>();
camera.ffmpeg?.inputs?.forEach((input) => {
input.roles.forEach((role) => roles.add(role as Role));
});
return {
nickname: camera?.nickname || cameraName,
name: cameraName,
roles,
};
}, [cameraName, config]); }, [cameraName, config]);
const defaultValues: FormValues = { const defaultValues: FormValues = {
cameraName: cameraName || "", cameraName: cameraInfo?.nickname || cameraName || "",
enabled: true, enabled: true,
ffmpeg: { ffmpeg: {
inputs: [ inputs: [
{ {
path: "", path: "",
roles: usedRoles.has("detect") ? [] : ["detect"], roles: cameraInfo.roles.has("detect") ? [] : ["detect"],
}, },
], ],
}, },
@ -154,10 +168,19 @@ export default function CameraEditForm({
const saveCameraConfig = (values: FormValues) => { const saveCameraConfig = (values: FormValues) => {
setIsLoading(true); 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"] = { const configData: ConfigSetBody["config_data"] = {
cameras: { cameras: {
[values.cameraName]: { [finalCameraName]: {
enabled: values.enabled, enabled: values.enabled,
...(nickname && { nickname }),
ffmpeg: { ffmpeg: {
inputs: values.ffmpeg.inputs.map((input) => ({ inputs: values.ffmpeg.inputs.map((input) => ({
path: input.path, path: input.path,
@ -175,7 +198,7 @@ export default function CameraEditForm({
// Add update_topic for new cameras // Add update_topic for new cameras
if (!cameraName) { if (!cameraName) {
requestBody.update_topic = `config/cameras/${values.cameraName}/add`; requestBody.update_topic = `config/cameras/${finalCameraName}/add`;
} }
axios axios
@ -209,7 +232,11 @@ export default function CameraEditForm({
}; };
const onSubmit = (values: FormValues) => { 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 // If camera name changed, delete old camera config
const deleteRequestBody: ConfigSetBody = { const deleteRequestBody: ConfigSetBody = {
requires_restart: 1, requires_restart: 1,

View File

@ -33,6 +33,7 @@ import { Link } from "react-router-dom";
import { LiveStreamMetadata } from "@/types/live"; import { LiveStreamMetadata } from "@/types/live";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { useCameraNickname } from "@/hooks/use-camera-nickname";
type CameraStreamingDialogProps = { type CameraStreamingDialogProps = {
camera: string; camera: string;
@ -56,6 +57,8 @@ export function CameraStreamingDialog({
const { getLocaleDocUrl } = useDocDomain(); const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const cameraName = useCameraNickname(camera);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [streamName, setStreamName] = useState( const [streamName, setStreamName] = useState(
@ -190,7 +193,7 @@ export function CameraStreamingDialog({
<DialogHeader className="mb-4"> <DialogHeader className="mb-4">
<DialogTitle className="smart-capitalize"> <DialogTitle className="smart-capitalize">
{t("group.camera.setting.title", { {t("group.camera.setting.title", {
cameraName: camera.replaceAll("_", " "), cameraName: cameraName,
})} })}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>

View File

@ -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<FrigateConfig>("config");
const name = useMemo(
() => resolveCameraName(config, cameraId),
[config, cameraId],
);
return name;
}

View File

@ -8,7 +8,7 @@ import { FrigateStats, PotentialProblem } from "@/types/stats";
import { useMemo } from "react"; import { useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
import useDeepMemo from "./use-deep-memo"; import useDeepMemo from "./use-deep-memo";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil";
import { useFrigateStats } from "@/api/ws"; import { useFrigateStats } from "@/api/ws";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -61,10 +61,11 @@ export default function useStats(stats: FrigateStats | undefined) {
return; return;
} }
const cameraName = config.cameras?.[name]?.nickname ?? name;
if (config.cameras[name].enabled && cam["camera_fps"] == 0) { if (config.cameras[name].enabled && cam["camera_fps"] == 0) {
problems.push({ problems.push({
text: t("stats.cameraIsOffline", { text: t("stats.cameraIsOffline", {
camera: capitalizeFirstLetter(name.replaceAll("_", " ")), camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
}), }),
color: "text-danger", color: "text-danger",
relevantLink: "logs", relevantLink: "logs",
@ -81,10 +82,11 @@ export default function useStats(stats: FrigateStats | undefined) {
memoizedStats["cpu_usages"][cam["pid"]]?.cpu_average, memoizedStats["cpu_usages"][cam["pid"]]?.cpu_average,
); );
const cameraName = config?.cameras?.[name]?.nickname ?? name;
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) { if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
problems.push({ problems.push({
text: t("stats.ffmpegHighCpuUsage", { text: t("stats.ffmpegHighCpuUsage", {
camera: capitalizeFirstLetter(name.replaceAll("_", " ")), camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
ffmpegAvg, ffmpegAvg,
}), }),
color: "text-danger", color: "text-danger",
@ -95,7 +97,7 @@ export default function useStats(stats: FrigateStats | undefined) {
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) { if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
problems.push({ problems.push({
text: t("stats.detectHighCpuUsage", { text: t("stats.detectHighCpuUsage", {
camera: capitalizeFirstLetter(name.replaceAll("_", " ")), camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
detectAvg, detectAvg,
}), }),
color: "text-danger", color: "text-danger",

View File

@ -46,6 +46,7 @@ import { isPWA } from "@/utils/isPWA";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import TriggerView from "@/views/settings/TriggerView"; import TriggerView from "@/views/settings/TriggerView";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
const allSettingsViews = [ const allSettingsViews = [
"ui", "ui",
@ -351,9 +352,11 @@ function CameraSelectButton({
> >
<FaVideo className="text-background dark:text-primary" /> <FaVideo className="text-background dark:text-primary" />
<div className="hidden text-background dark:text-primary md:block"> <div className="hidden text-background dark:text-primary md:block">
{selectedCamera == undefined {selectedCamera == undefined ? (
? t("cameraSetting.noCamera") t("cameraSetting.noCamera")
: selectedCamera.replaceAll("_", " ")} ) : (
<CameraNameLabel camera={selectedCamera} />
)}
</div> </div>
</Button> </Button>
); );
@ -376,7 +379,8 @@ function CameraSelectButton({
<FilterSwitch <FilterSwitch
key={item.name} key={item.name}
isChecked={item.name === selectedCamera} isChecked={item.name === selectedCamera}
label={item.name.replaceAll("_", " ")} label={item.name}
isCameraName={true}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked && (isEnabled || isCameraSettingsPage)) { if (isChecked && (isEnabled || isCameraSettingsPage)) {
setSelectedCamera(item.name); setSelectedCamera(item.name);

View File

@ -33,6 +33,7 @@ export type SearchModel = "jinav1" | "jinav2";
export type SearchModelSize = "small" | "large"; export type SearchModelSize = "small" | "large";
export interface CameraConfig { export interface CameraConfig {
nickname: string;
audio: { audio: {
enabled: boolean; enabled: boolean;
enabled_in_config: boolean; enabled_in_config: boolean;

View File

@ -64,6 +64,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type RecordingViewProps = { type RecordingViewProps = {
startCamera: string; startCamera: string;
@ -719,7 +720,7 @@ export function RecordingView({
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="smart-capitalize"> <TooltipContent className="smart-capitalize">
{cam.replaceAll("_", " ")} <CameraNameLabel camera={cam} />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );

View File

@ -49,6 +49,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { useCameraNickname } from "@/hooks/use-camera-nickname";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type CameraSettingsViewProps = { type CameraSettingsViewProps = {
selectedCamera: string; selectedCamera: string;
@ -96,6 +98,8 @@ export default function CameraSettingsView({
return []; return [];
}, [config]); }, [config]);
const selectCameraName = useCameraNickname(selectedCamera);
// zones and labels // zones and labels
const zones = useMemo(() => { const zones = useMemo(() => {
@ -337,11 +341,13 @@ export default function CameraSettingsView({
<SelectValue placeholder={t("camera.selectCamera")} /> <SelectValue placeholder={t("camera.selectCamera")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{cameras.map((camera) => ( {cameras.map((camera) => {
<SelectItem key={camera} value={camera}> return (
{capitalizeFirstLetter(camera.replaceAll("_", " "))} <SelectItem key={camera} value={camera}>
</SelectItem> <CameraNameLabel camera={camera} />
))} </SelectItem>
);
})}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -612,18 +618,14 @@ export default function CameraSettingsView({
), ),
) )
.join(", "), .join(", "),
cameraName: capitalizeFirstLetter( cameraName: selectCameraName,
cameraConfig?.name ?? "",
).replaceAll("_", " "),
}, },
) )
: t( : t(
"camera.reviewClassification.objectAlertsTips", "camera.reviewClassification.objectAlertsTips",
{ {
alertsLabels, alertsLabels,
cameraName: capitalizeFirstLetter( cameraName: selectCameraName,
cameraConfig?.name ?? "",
).replaceAll("_", " "),
}, },
)} )}
</div> </div>
@ -735,9 +737,7 @@ export default function CameraSettingsView({
), ),
) )
.join(", "), .join(", "),
cameraName: capitalizeFirstLetter( cameraName: selectCameraName,
cameraConfig?.name ?? "",
).replaceAll("_", " "),
}} }}
ns="views/settings" ns="views/settings"
/> />
@ -754,9 +754,7 @@ export default function CameraSettingsView({
), ),
) )
.join(", "), .join(", "),
cameraName: capitalizeFirstLetter( cameraName: selectCameraName,
cameraConfig?.name ?? "",
).replaceAll("_", " "),
}} }}
ns="views/settings" ns="views/settings"
/> />
@ -766,9 +764,7 @@ export default function CameraSettingsView({
i18nKey="camera.reviewClassification.objectDetectionsTips" i18nKey="camera.reviewClassification.objectDetectionsTips"
values={{ values={{
detectionsLabels, detectionsLabels,
cameraName: capitalizeFirstLetter( cameraName: selectCameraName,
cameraConfig?.name ?? "",
).replaceAll("_", " "),
}} }}
ns="views/settings" ns="views/settings"
/> />

View File

@ -23,6 +23,7 @@ import {
SelectTrigger, SelectTrigger,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
type FrigatePlusModel = { type FrigatePlusModel = {
id: string; id: string;
@ -488,7 +489,9 @@ export default function FrigatePlusSettingsView({
key={name} key={name}
className="border-b border-secondary" className="border-b border-secondary"
> >
<td className="px-4 py-2">{name}</td> <td className="px-4 py-2">
<CameraNameLabel camera={name} />
</td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
{camera.snapshots.enabled ? ( {camera.snapshots.enabled ? (
<CheckCircle2 className="mx-auto size-5 text-green-500" /> <CheckCircle2 className="mx-auto size-5 text-green-500" />

View File

@ -11,7 +11,6 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { StatusBarMessagesContext } from "@/context/statusbar-provider"; 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 { Trans, useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale"; import { useDateLocale } from "@/hooks/use-date-locale";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js"; const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
@ -464,7 +464,8 @@ export default function NotificationView({
{allCameras?.map((camera) => ( {allCameras?.map((camera) => (
<FilterSwitch <FilterSwitch
key={camera.name} key={camera.name}
label={camera.name.replaceAll("_", " ")} label={camera.name}
isCameraName={true}
isChecked={field.value?.includes(camera.name)} isChecked={field.value?.includes(camera.name)}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
setChangedValue(true); setChangedValue(true);
@ -697,12 +698,11 @@ export function CameraNotificationSwitch({
<LuX className="size-6 text-danger" /> <LuX className="size-6 text-danger" />
)} )}
<div className="flex flex-col"> <div className="flex flex-col">
<Label <CameraNameLabel
className="text-md cursor-pointer text-primary smart-capitalize" className="text-md cursor-pointer text-primary smart-capitalize"
htmlFor="camera" htmlFor="camera"
> camera={camera}
{camera.replaceAll("_", " ")} />
</Label>
{!isSuspended ? ( {!isSuspended ? (
<div className="flex flex-row items-center gap-2 text-sm text-success"> <div className="flex flex-row items-center gap-2 text-sm text-success">

View File

@ -30,6 +30,7 @@ import { isDesktop } from "react-device-detect";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { useCameraNickname } from "@/hooks/use-camera-nickname";
import { AudioLevelGraph } from "@/components/audio/AudioLevelGraph"; import { AudioLevelGraph } from "@/components/audio/AudioLevelGraph";
import { useWs } from "@/api/ws"; import { useWs } from "@/api/ws";
@ -128,6 +129,8 @@ export default function ObjectSettingsView({
} }
}, [config, selectedCamera]); }, [config, selectedCamera]);
const cameraName = useCameraNickname(cameraConfig);
const { objects, audio_detections } = useCameraActivity( const { objects, audio_detections } = useCameraActivity(
cameraConfig ?? ({} as CameraConfig), cameraConfig ?? ({} as CameraConfig),
); );
@ -186,7 +189,9 @@ export default function ObjectSettingsView({
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline" className="inline"
> >
Open {capitalizeFirstLetter(cameraConfig.name)}'s Web UI {t("debug.openCameraWebUI", {
camera: cameraName,
})}
<LuExternalLink className="ml-2 inline-flex size-3" /> <LuExternalLink className="ml-2 inline-flex size-3" />
</Link> </Link>
</div> </div>

View File

@ -23,6 +23,7 @@ import { cn } from "@/lib/utils";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTriggers } from "@/api/ws"; import { useTriggers } from "@/api/ws";
import { useCameraNickname } from "@/hooks/use-camera-nickname";
type ConfigSetBody = { type ConfigSetBody = {
requires_restart: number; requires_restart: number;
@ -78,6 +79,7 @@ export default function TriggerView({
const [triggeredTrigger, setTriggeredTrigger] = useState<string>(); const [triggeredTrigger, setTriggeredTrigger] = useState<string>();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const cameraName = useCameraNickname(selectedCamera);
const triggers = useMemo(() => { const triggers = useMemo(() => {
if ( if (
!config || !config ||
@ -390,7 +392,9 @@ export default function TriggerView({
{t("triggers.management.title")} {t("triggers.management.title")}
</Heading> </Heading>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("triggers.management.desc", { camera: selectedCamera })} {t("triggers.management.desc", {
camera: cameraName,
})}
</p> </p>
</div> </div>
<Button <Button

View File

@ -4,7 +4,7 @@ import CameraInfoDialog from "@/components/overlay/CameraInfoDialog";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { MdInfo } from "react-icons/md"; import { MdInfo } from "react-icons/md";
import { import {
Tooltip, Tooltip,
@ -13,6 +13,8 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import useSWR from "swr"; import useSWR from "swr";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CameraNameLabel } from "@/components/camera/CameraNameLabel";
import { resolveCameraName } from "@/hooks/use-camera-nickname";
type CameraMetricsProps = { type CameraMetricsProps = {
lastUpdated: number; lastUpdated: number;
@ -26,6 +28,11 @@ export default function CameraMetrics({
const { t } = useTranslation(["views/system"]); const { t } = useTranslation(["views/system"]);
// camera info dialog // camera info dialog
const getCameraName = useCallback(
(cameraId: string) => resolveCameraName(config, cameraId),
[config],
);
const [showCameraInfoDialog, setShowCameraInfoDialog] = useState(false); const [showCameraInfoDialog, setShowCameraInfoDialog] = useState(false);
const [probeCameraName, setProbeCameraName] = useState<string>(); const [probeCameraName, setProbeCameraName] = useState<string>();
@ -142,7 +149,7 @@ export default function CameraMetrics({
} }
if (!(key in series)) { if (!(key in series)) {
const camName = key.replaceAll("_", " "); const camName = getCameraName(key);
series[key] = {}; series[key] = {};
series[key]["ffmpeg"] = { series[key]["ffmpeg"] = {
name: t("cameras.label.cameraFfmpeg", { camName: camName }), name: t("cameras.label.cameraFfmpeg", { camName: camName }),
@ -173,7 +180,7 @@ export default function CameraMetrics({
}); });
}); });
return series; return series;
}, [config, statsHistory, t]); }, [config, getCameraName, statsHistory, t]);
const cameraFpsSeries = useMemo(() => { const cameraFpsSeries = useMemo(() => {
if (!statsHistory) { if (!statsHistory) {
@ -193,7 +200,7 @@ export default function CameraMetrics({
Object.entries(stats.cameras).forEach(([key, camStats]) => { Object.entries(stats.cameras).forEach(([key, camStats]) => {
if (!(key in series)) { if (!(key in series)) {
const camName = key.replaceAll("_", " "); const camName = getCameraName(key);
series[key] = {}; series[key] = {};
series[key]["fps"] = { series[key]["fps"] = {
name: t("cameras.label.cameraFramesPerSecond", { name: t("cameras.label.cameraFramesPerSecond", {
@ -230,7 +237,7 @@ export default function CameraMetrics({
}); });
}); });
return series; return series;
}, [statsHistory, t]); }, [getCameraName, statsHistory, t]);
useEffect(() => { useEffect(() => {
if (!showCameraInfoDialog) { if (!showCameraInfoDialog) {
@ -276,7 +283,7 @@ export default function CameraMetrics({
<div className="flex w-full flex-col gap-3"> <div className="flex w-full flex-col gap-3">
<div className="flex flex-row items-center justify-between"> <div className="flex flex-row items-center justify-between">
<div className="text-sm font-medium text-muted-foreground smart-capitalize"> <div className="text-sm font-medium text-muted-foreground smart-capitalize">
{camera.name.replaceAll("_", " ")} <CameraNameLabel camera={camera} />
</div> </div>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>