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
31 changed files with 276 additions and 99 deletions

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

View File

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

View File

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

View File

@@ -53,6 +53,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { MdImageSearch } from "react-icons/md";
import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n";
import { CameraNameLabel } from "../camera/CameraNameLabel";
type InputWithTagsProps = {
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"
>
{t("filter.label." + filterType)}:{" "}
{filterType === "labels"
? getTranslatedLabel(value)
: value.replaceAll("_", " ")}
{filterType === "labels" ? (
getTranslatedLabel(value)
) : filterType === "cameras" ? (
<CameraNameLabel camera={value} />
) : (
value.replaceAll("_", " ")
)}
<button
onClick={() =>
removeFilter(filterType as FilterType, value)
@@ -923,13 +928,27 @@ export default function InputWithTags({
onSelect={() => handleSuggestionClick(suggestion)}
>
{i18n.language === "en" ? (
suggestion
currentFilterType && currentFilterType === "cameras" ? (
<>
{suggestion} {" ("}{" "}
<CameraNameLabel camera={suggestion} />
{")"}
</>
) : (
suggestion
)
) : (
<>
{suggestion} {" ("}
{currentFilterType
? formatFilterValues(currentFilterType, suggestion)
: t("filter.label." + suggestion)}
{currentFilterType ? (
currentFilterType === "cameras" ? (
<CameraNameLabel camera={suggestion} />
) : (
formatFilterValues(currentFilterType, suggestion)
)
) : (
t("filter.label." + suggestion)
)}
{")"}
</>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import { baseUrl } from "@/api/baseUrl";
import { PlayerStats } from "./PlayerStats";
import { LuVideoOff } from "react-icons/lu";
import { Trans, useTranslation } from "react-i18next";
import { useCameraNickname } from "@/hooks/use-camera-nickname";
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
@@ -76,6 +77,7 @@ export default function LivePlayer({
const internalContainerRef = useRef<HTMLDivElement | null>(null);
const cameraName = useCameraNickname(cameraConfig);
// stats
const [stats, setStats] = useState<PlayerStatsType>({
@@ -412,7 +414,7 @@ export default function LivePlayer({
<Trans
ns="components/player"
values={{
cameraName: capitalizeFirstLetter(cameraConfig.name),
cameraName: cameraName,
}}
>
streamOffline.desc
@@ -444,7 +446,7 @@ export default function LivePlayer({
<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`}
>
{cameraConfig.name.replaceAll("_", " ")}
{cameraName}
</Chip>
)}
</div>

View File

@@ -21,6 +21,7 @@ import {
usePreviewForTimeRange,
} from "@/hooks/use-camera-previews";
import { useTranslation } from "react-i18next";
import { useCameraNickname } from "@/hooks/use-camera-nickname";
type PreviewPlayerProps = {
previewRef?: (ref: HTMLDivElement | null) => void;
@@ -148,6 +149,7 @@ function PreviewVideoPlayer({
const { t } = useTranslation(["components/player"]);
const { data: config } = useSWR<FrigateConfig>("config");
const cameraName = useCameraNickname(camera);
// controlling playback
const previewRef = useRef<HTMLVideoElement | null>(null);
@@ -342,7 +344,7 @@ function PreviewVideoPlayer({
)}
{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">
{t("noPreviewFoundFor", { camera: camera.replaceAll("_", " ") })}
{t("noPreviewFoundFor", { camera: cameraName })}
</div>
)}
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
@@ -464,6 +466,7 @@ function PreviewFramesPlayer({
}: PreviewFramesPlayerProps) {
const { t } = useTranslation(["components/player"]);
const cameraName = useCameraNickname(camera);
// frames data
const { data: previewFrames } = useSWR<string[]>(
@@ -564,7 +567,7 @@ function PreviewFramesPlayer({
/>
{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">
{t("noPreviewFoundFor", { cameraName: camera.replaceAll("_", " ") })}
{t("noPreviewFoundFor", { cameraName: cameraName })}
</div>
)}
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}

View File

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

View File

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