mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-08-31 13:48:19 +02:00
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:
parent
195f705616
commit
d3af748366
@ -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(
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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",
|
||||||
|
@ -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",
|
||||||
|
24
web/src/components/camera/CameraNameLabel.tsx
Normal file
24
web/src/components/camera/CameraNameLabel.tsx
Normal 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 };
|
@ -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" && (
|
||||||
|
@ -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 &&
|
||||||
|
@ -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}
|
||||||
|
@ -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)
|
||||||
|
)}
|
||||||
{")"}
|
{")"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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" />}
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
|
29
web/src/hooks/use-camera-nickname.ts
Normal file
29
web/src/hooks/use-camera-nickname.ts
Normal 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;
|
||||||
|
}
|
@ -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",
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
@ -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" />
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user