mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-09-10 17:51:45 +02:00
feat: camera edit form add go2rtc
This commit is contained in:
parent
d78b6e528b
commit
78e431d5e3
@ -80,6 +80,31 @@ class CameraConfigUpdateSubscriber:
|
|||||||
self.camera_configs[camera] = updated_config
|
self.camera_configs[camera] = updated_config
|
||||||
return
|
return
|
||||||
elif update_type == CameraConfigUpdateEnum.remove:
|
elif update_type == CameraConfigUpdateEnum.remove:
|
||||||
|
"""Remove go2rtc streams with camera"""
|
||||||
|
camera_config = self.camera_configs.get(camera)
|
||||||
|
if (
|
||||||
|
camera_config
|
||||||
|
and hasattr(self.config, "go2rtc")
|
||||||
|
and hasattr(camera_config, "ffmpeg")
|
||||||
|
and hasattr(camera_config.ffmpeg, "inputs")
|
||||||
|
):
|
||||||
|
for input_item in camera_config.ffmpeg.inputs:
|
||||||
|
if hasattr(input_item, "path") and isinstance(input_item.path, str):
|
||||||
|
if (
|
||||||
|
"rtsp://" in input_item.path
|
||||||
|
and "127.0.0.1:8554/" in input_item.path
|
||||||
|
):
|
||||||
|
stream_name = (
|
||||||
|
input_item.path.split("127.0.0.1:8554/")[-1]
|
||||||
|
.split("&")[0]
|
||||||
|
.split("?")[0]
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
hasattr(self.config.go2rtc, "streams")
|
||||||
|
and stream_name in self.config.go2rtc.streams
|
||||||
|
):
|
||||||
|
self.config.go2rtc.streams.pop(stream_name)
|
||||||
|
|
||||||
self.config.cameras.pop(camera)
|
self.config.cameras.pop(camera)
|
||||||
self.camera_configs.pop(camera)
|
self.camera_configs.pop(camera)
|
||||||
return
|
return
|
||||||
|
@ -198,10 +198,59 @@
|
|||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"ffmpeg": {
|
"ffmpeg": {
|
||||||
"inputs": "Input Streams",
|
"inputs": "Input Streams",
|
||||||
|
"go2rtc": {
|
||||||
|
"title": "Use go2rtc stream",
|
||||||
|
"description": "will use go2rtc",
|
||||||
|
"compatibility": {
|
||||||
|
"title": "Video codec compatibility (Advanced)",
|
||||||
|
"description": "Here you can configure common go2rtc FFmpeg encoding parameters. Some of these parameters may help resolve certain video or audio issues.",
|
||||||
|
"codec": {
|
||||||
|
"title": "Video Codec",
|
||||||
|
"description": "You can use go2rtc to convert the stream to a specified encoding. Note that converting video from one format to another is a resource-intensive task, so additional conversion is not recommended if the original setup works properly.",
|
||||||
|
"h264": "H.264",
|
||||||
|
"h265": "H.265",
|
||||||
|
"copy": "Copy (default)"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"title": "Audio Codec",
|
||||||
|
"description": "If your camera does not support AAC audio, you can use this parameter to convert the audio to AAC. If you can see the video but hear no sound, try adding this parameter.",
|
||||||
|
"copy": "Copy (default)",
|
||||||
|
"aac": "AAC",
|
||||||
|
"opus": "Opus"
|
||||||
|
},
|
||||||
|
"rotate": {
|
||||||
|
"title": "Rotation",
|
||||||
|
"description": "Some cameras may not set the correct rotation flag in the video stream. If your camera's video appears rotated incorrectly, you can use this parameter to manually set the rotation.",
|
||||||
|
"90": "90°",
|
||||||
|
"180": "180°",
|
||||||
|
"270": "270°"
|
||||||
|
},
|
||||||
|
"hardware": {
|
||||||
|
"title": "Hardware",
|
||||||
|
"description": "If you are using a hardware encoder, you can use this parameter to specify the hardware encoder to use.",
|
||||||
|
"useHardware": "Force use hardware"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"path": "Stream Path",
|
"path": "Stream Path",
|
||||||
"pathRequired": "Stream path is required",
|
"pathRequired": "Stream path is required",
|
||||||
"pathPlaceholder": "rtsp://...",
|
"pathPlaceholder": "rtsp://...",
|
||||||
"roles": "Roles",
|
"roles": {
|
||||||
|
"title": "Roles",
|
||||||
|
"record": {
|
||||||
|
"label": "Record",
|
||||||
|
"info": "5555"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"label": "Audio",
|
||||||
|
"info": "6666"
|
||||||
|
},
|
||||||
|
"detect": {
|
||||||
|
"label": "Detect",
|
||||||
|
"info": "77777"
|
||||||
|
}
|
||||||
|
},
|
||||||
"rolesRequired": "At least one role is required",
|
"rolesRequired": "At least one role is required",
|
||||||
"rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream",
|
"rolesUnique": "Each role (audio, detect, record) can only be assigned to one stream",
|
||||||
"addInput": "Add Input Stream",
|
"addInput": "Add Input Stream",
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input, MultiSelectInput } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@ -22,6 +22,14 @@ import { LuTrash2, LuPlus } from "react-icons/lu";
|
|||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import i18n from "@/utils/i18n";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
type ConfigSetBody = {
|
type ConfigSetBody = {
|
||||||
requires_restart: number;
|
requires_restart: number;
|
||||||
@ -55,6 +63,100 @@ export default function CameraEditForm({
|
|||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const go2rtc_ffmpegOptions = [
|
||||||
|
{
|
||||||
|
id: "codec",
|
||||||
|
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.title"),
|
||||||
|
description: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.description",
|
||||||
|
),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "#video=h264",
|
||||||
|
label: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.h264",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "#video=h265",
|
||||||
|
label: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.h265",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "#video=copy",
|
||||||
|
label: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.codec.copy",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "audio",
|
||||||
|
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.title"),
|
||||||
|
description: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.description",
|
||||||
|
),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "#audio=aac",
|
||||||
|
label: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.aac"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "#audio=opus",
|
||||||
|
label: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.opus",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "#audio=copy",
|
||||||
|
label: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.audio.copy",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rotate",
|
||||||
|
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.title"),
|
||||||
|
description: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.description",
|
||||||
|
),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "#rotate=90",
|
||||||
|
label: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.90"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "#rotate=180",
|
||||||
|
label: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.180",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "#rotate=270",
|
||||||
|
label: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.rotate.270",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hardware",
|
||||||
|
name: t("camera.cameraConfig.ffmpeg.go2rtc.compatibility.hardware.title"),
|
||||||
|
description: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.hardware.description",
|
||||||
|
),
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "#hardware",
|
||||||
|
label: t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.hardware.useHardware",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
const formSchema = useMemo(
|
const formSchema = useMemo(
|
||||||
() =>
|
() =>
|
||||||
z.object({
|
z.object({
|
||||||
@ -72,6 +174,8 @@ export default function CameraEditForm({
|
|||||||
roles: z.array(RoleEnum).min(1, {
|
roles: z.array(RoleEnum).min(1, {
|
||||||
message: t("camera.cameraConfig.ffmpeg.rolesRequired"),
|
message: t("camera.cameraConfig.ffmpeg.rolesRequired"),
|
||||||
}),
|
}),
|
||||||
|
go2rtc: z.boolean(),
|
||||||
|
go2rtc_ffmpeg: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.min(1, {
|
.min(1, {
|
||||||
@ -133,6 +237,8 @@ export default function CameraEditForm({
|
|||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
inputs: [
|
inputs: [
|
||||||
{
|
{
|
||||||
|
go2rtc: false,
|
||||||
|
go2rtc_ffmpeg: "",
|
||||||
path: "",
|
path: "",
|
||||||
roles: cameraInfo.roles.has("detect") ? [] : ["detect"],
|
roles: cameraInfo.roles.has("detect") ? [] : ["detect"],
|
||||||
},
|
},
|
||||||
@ -145,10 +251,51 @@ export default function CameraEditForm({
|
|||||||
const camera = config.cameras[cameraName];
|
const camera = config.cameras[cameraName];
|
||||||
defaultValues.enabled = camera.enabled ?? true;
|
defaultValues.enabled = camera.enabled ?? true;
|
||||||
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length
|
defaultValues.ffmpeg.inputs = camera.ffmpeg?.inputs?.length
|
||||||
? camera.ffmpeg.inputs.map((input) => ({
|
? camera.ffmpeg.inputs.map((input) => {
|
||||||
path: input.path,
|
const isGo2rtcPath = input.path.match(
|
||||||
roles: input.roles as Role[],
|
/^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
|
||||||
}))
|
);
|
||||||
|
const go2rtcStreamName = isGo2rtcPath ? isGo2rtcPath[1] : null;
|
||||||
|
|
||||||
|
let originalPath = input.path;
|
||||||
|
let ffmpegParams = "";
|
||||||
|
|
||||||
|
if (go2rtcStreamName && config.go2rtc?.streams) {
|
||||||
|
Object.entries(config.go2rtc.streams).forEach(
|
||||||
|
([streamKey, streamConfig]) => {
|
||||||
|
if (
|
||||||
|
streamKey === go2rtcStreamName ||
|
||||||
|
streamKey.startsWith(`${cameraName}_`)
|
||||||
|
) {
|
||||||
|
if (Array.isArray(streamConfig) && streamConfig.length >= 1) {
|
||||||
|
originalPath = streamConfig[0] || "";
|
||||||
|
ffmpegParams = streamConfig[1] || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
originalPath === input.path &&
|
||||||
|
config.go2rtc.streams[go2rtcStreamName]
|
||||||
|
) {
|
||||||
|
const streamConfig = config.go2rtc.streams[go2rtcStreamName];
|
||||||
|
if (Array.isArray(streamConfig) && streamConfig.length >= 1) {
|
||||||
|
originalPath = streamConfig[0] || "";
|
||||||
|
ffmpegParams = streamConfig[1] || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: isGo2rtcPath ? originalPath : input.path,
|
||||||
|
roles: input.roles as Role[],
|
||||||
|
go2rtc: !!isGo2rtcPath,
|
||||||
|
go2rtc_ffmpeg: isGo2rtcPath
|
||||||
|
? ffmpegParams
|
||||||
|
: config.go2rtc?.streams?.[cameraName]?.[1] || "",
|
||||||
|
};
|
||||||
|
})
|
||||||
: defaultValues.ffmpeg.inputs;
|
: defaultValues.ffmpeg.inputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,7 +330,9 @@ export default function CameraEditForm({
|
|||||||
...(friendly_name && { friendly_name }),
|
...(friendly_name && { friendly_name }),
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
inputs: values.ffmpeg.inputs.map((input) => ({
|
inputs: values.ffmpeg.inputs.map((input) => ({
|
||||||
path: input.path,
|
path: input.go2rtc
|
||||||
|
? `rtsp://127.0.0.1:8554/${finalCameraName}_${input.roles.join("_")}`
|
||||||
|
: input.path,
|
||||||
roles: input.roles,
|
roles: input.roles,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@ -191,6 +340,24 @@ export default function CameraEditForm({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasGo2rtcEnabled = values.ffmpeg.inputs.some((input) => input.go2rtc);
|
||||||
|
|
||||||
|
if (hasGo2rtcEnabled) {
|
||||||
|
const go2rtcStreams: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
values.ffmpeg.inputs.forEach((input) => {
|
||||||
|
if (input.go2rtc) {
|
||||||
|
const streamName = `${finalCameraName}_${input.roles.join("_")}`;
|
||||||
|
|
||||||
|
go2rtcStreams[streamName] = [input.path, input.go2rtc_ffmpeg || ""];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
configData.go2rtc = {
|
||||||
|
streams: go2rtcStreams,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const requestBody: ConfigSetBody = {
|
const requestBody: ConfigSetBody = {
|
||||||
requires_restart: 1,
|
requires_restart: 1,
|
||||||
config_data: configData,
|
config_data: configData,
|
||||||
@ -232,11 +399,7 @@ export default function CameraEditForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
if (
|
if (cameraName && values.cameraName !== cameraName) {
|
||||||
cameraName &&
|
|
||||||
values.cameraName !== cameraName &&
|
|
||||||
values.cameraName !== cameraInfo?.friendly_name
|
|
||||||
) {
|
|
||||||
// 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,
|
||||||
@ -247,6 +410,20 @@ export default function CameraEditForm({
|
|||||||
},
|
},
|
||||||
update_topic: `config/cameras/${cameraName}/remove`,
|
update_topic: `config/cameras/${cameraName}/remove`,
|
||||||
};
|
};
|
||||||
|
const camera = config?.cameras[cameraName];
|
||||||
|
if (values.ffmpeg.inputs.some((input) => input.go2rtc)) {
|
||||||
|
camera?.ffmpeg.inputs.map((input) => {
|
||||||
|
const isGo2rtcPath = input.path.match(
|
||||||
|
/^rtsp:\/\/127\.0\.0\.1:8554\/(.+)$/,
|
||||||
|
);
|
||||||
|
const go2rtcStreamName = isGo2rtcPath ? isGo2rtcPath[1] : "";
|
||||||
|
deleteRequestBody.config_data.go2rtc = {
|
||||||
|
streams: {
|
||||||
|
[go2rtcStreamName]: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.put("config/set", deleteRequestBody)
|
.put("config/set", deleteRequestBody)
|
||||||
@ -372,32 +549,56 @@ export default function CameraEditForm({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("camera.cameraConfig.ffmpeg.roles")}
|
{t("camera.cameraConfig.ffmpeg.roles.title")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(["audio", "detect", "record"] as const).map(
|
{(["audio", "detect", "record"] as const).map(
|
||||||
(role) => (
|
(role) => (
|
||||||
<label
|
<Tooltip>
|
||||||
key={role}
|
<TooltipTrigger asChild>
|
||||||
className="flex items-center space-x-2"
|
<label
|
||||||
>
|
key={role}
|
||||||
<input
|
className="flex items-center space-x-2"
|
||||||
type="checkbox"
|
>
|
||||||
checked={field.value.includes(role)}
|
<input
|
||||||
onChange={(e) => {
|
type="checkbox"
|
||||||
const updatedRoles = e.target.checked
|
checked={field.value.includes(role)}
|
||||||
? [...field.value, role]
|
onChange={(e) => {
|
||||||
: field.value.filter((r) => r !== role);
|
const updatedRoles = e.target.checked
|
||||||
field.onChange(updatedRoles);
|
? [...field.value, role]
|
||||||
}}
|
: field.value.filter(
|
||||||
disabled={
|
(r) => r !== role,
|
||||||
!field.value.includes(role) &&
|
);
|
||||||
getUsedRolesExcludingIndex(index).has(role)
|
field.onChange(updatedRoles);
|
||||||
}
|
}}
|
||||||
/>
|
disabled={
|
||||||
<span>{role}</span>
|
!field.value.includes(role) &&
|
||||||
</label>
|
getUsedRolesExcludingIndex(index).has(
|
||||||
|
role,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{i18n.language === "en"
|
||||||
|
? role
|
||||||
|
: t(
|
||||||
|
`camera.cameraConfig.ffmpeg.roles.${role}.label`,
|
||||||
|
) +
|
||||||
|
"(" +
|
||||||
|
role +
|
||||||
|
")"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent>
|
||||||
|
{t(
|
||||||
|
`camera.cameraConfig.ffmpeg.roles.${role}.info`,
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -406,7 +607,73 @@ export default function CameraEditForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`ffmpeg.inputs.${index}.go2rtc`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("camera.cameraConfig.ffmpeg.go2rtc.title")}
|
||||||
|
<div className="my-3 text-sm text-muted-foreground">
|
||||||
|
{t("camera.cameraConfig.ffmpeg.go2rtc.description")}
|
||||||
|
</div>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
<FormLabel>
|
||||||
|
{t("camera.cameraConfig.enabled")}
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`ffmpeg.inputs.${index}.go2rtc_ffmpeg`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
{form.watch(`ffmpeg.inputs.${index}.go2rtc`) ? (
|
||||||
|
<>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.title",
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<div className="my-3 text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.description",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<MultiSelectInput
|
||||||
|
parameterGroups={go2rtc_ffmpegOptions}
|
||||||
|
onParameterChange={(params) => {
|
||||||
|
const combinedValue =
|
||||||
|
Object.values(params).join("");
|
||||||
|
field.onChange(combinedValue);
|
||||||
|
}}
|
||||||
|
parameterPlaceholder={t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.select",
|
||||||
|
)}
|
||||||
|
inputPlaceholder={t(
|
||||||
|
"camera.cameraConfig.ffmpeg.go2rtc.compatibility.custom",
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
disabled={!!cameraName} // Prevent editing name for existing cameras
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -426,7 +693,9 @@ export default function CameraEditForm({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
onClick={() => append({ path: "", roles: getAvailableRoles() })}
|
onClick={() =>
|
||||||
|
append({ path: "", roles: getAvailableRoles(), go2rtc: false })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<LuPlus className="mr-2 h-4 w-4" />
|
<LuPlus className="mr-2 h-4 w-4" />
|
||||||
{t("camera.cameraConfig.ffmpeg.addInput")}
|
{t("camera.cameraConfig.ffmpeg.addInput")}
|
||||||
|
@ -22,4 +22,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
)
|
)
|
||||||
Input.displayName = "Input"
|
Input.displayName = "Input"
|
||||||
|
|
||||||
export { Input }
|
import { SelectInput } from "./select-input"
|
||||||
|
import { MultiSelectInput } from "./multi-select-input"
|
||||||
|
|
||||||
|
export { Input, SelectInput, MultiSelectInput }
|
||||||
|
166
web/src/components/ui/multi-select-input.tsx
Normal file
166
web/src/components/ui/multi-select-input.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input, InputProps } from "./input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "./select"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { LuX } from "react-icons/lu"
|
||||||
|
|
||||||
|
export type ParameterOption = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParameterGroup = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
options: ParameterOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiSelectInputProps extends InputProps {
|
||||||
|
parameterGroups: ParameterGroup[]
|
||||||
|
onParameterChange?: (parameters: Record<string, string>) => void
|
||||||
|
parameterPlaceholder?: string
|
||||||
|
inputPlaceholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiSelectInput = React.forwardRef<HTMLInputElement, MultiSelectInputProps>(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
parameterGroups,
|
||||||
|
onParameterChange,
|
||||||
|
parameterPlaceholder = "Select...",
|
||||||
|
inputPlaceholder = "input...",
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const [selectedParameters, setSelectedParameters] = React.useState<Record<string, string>>({})
|
||||||
|
const [currentParameter, setCurrentParameter] = React.useState<string | null>(null)
|
||||||
|
const [inputValue, setInputValue] = React.useState("")
|
||||||
|
|
||||||
|
const getCurrentParameterOptions = () => {
|
||||||
|
if (!currentParameter) return []
|
||||||
|
const group = parameterGroups.find(g => g.id === currentParameter)
|
||||||
|
return group?.options || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleParameterSelect = (parameterId: string) => {
|
||||||
|
setCurrentParameter(parameterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOptionSelect = (value: string) => {
|
||||||
|
if (currentParameter) {
|
||||||
|
const newSelectedParameters = {
|
||||||
|
...selectedParameters,
|
||||||
|
[currentParameter]: value
|
||||||
|
}
|
||||||
|
setSelectedParameters(newSelectedParameters)
|
||||||
|
|
||||||
|
if (onParameterChange) {
|
||||||
|
onParameterChange(newSelectedParameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentParameter(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeParameter = (parameterId: string) => {
|
||||||
|
const newSelectedParameters = { ...selectedParameters }
|
||||||
|
delete newSelectedParameters[parameterId]
|
||||||
|
setSelectedParameters(newSelectedParameters)
|
||||||
|
|
||||||
|
if (onParameterChange) {
|
||||||
|
onParameterChange(newSelectedParameters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getParameterDisplayName = (parameterId: string) => {
|
||||||
|
const group = parameterGroups.find(g => g.id === parameterId)
|
||||||
|
return group?.name || parameterId
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOptionDisplayName = (parameterId: string, optionValue: string) => {
|
||||||
|
const group = parameterGroups.find(g => g.id === parameterId)
|
||||||
|
const option = group?.options.find(o => o.value === optionValue)
|
||||||
|
return option?.label || optionValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.keys(selectedParameters).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(selectedParameters).map(([parameterId, optionValue]) => (
|
||||||
|
<div
|
||||||
|
key={parameterId}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-sm text-green-800"
|
||||||
|
>
|
||||||
|
<span>{getParameterDisplayName(parameterId)}:</span>
|
||||||
|
<span>{getOptionDisplayName(parameterId, optionValue)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeParameter(parameterId)}
|
||||||
|
className="ml-1 rounded-full p-0.5 hover:bg-green-200"
|
||||||
|
>
|
||||||
|
<LuX className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={currentParameter || ""} onValueChange={handleParameterSelect}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder={parameterPlaceholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{parameterGroups
|
||||||
|
.filter(group => !(group.id in selectedParameters))
|
||||||
|
.map((group) => (
|
||||||
|
<SelectItem key={group.id} value={group.id}>
|
||||||
|
{group.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{currentParameter ? (
|
||||||
|
<Select value="" onValueChange={handleOptionSelect}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Select item..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{getCurrentParameterOptions().map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className={cn("flex-1", className)}
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="my-3 text-sm text-muted-foreground">{parameterGroups.find(g => g.id === currentParameter)?.description}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
MultiSelectInput.displayName = "MultiSelectInput"
|
||||||
|
|
||||||
|
export { MultiSelectInput }
|
@ -62,7 +62,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
|
const cameraName = config.cameras?.[name]?.friendly_name ?? 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(capitalizeAll(cameraName)),
|
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||||
|
@ -421,7 +421,7 @@ export interface FrigateConfig {
|
|||||||
};
|
};
|
||||||
|
|
||||||
go2rtc: {
|
go2rtc: {
|
||||||
streams: string[];
|
streams: { [streamName: string]: string[] };
|
||||||
webrtc: {
|
webrtc: {
|
||||||
candidates: string[];
|
candidates: string[];
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user