This commit is contained in:
Josh Hawkins
2026-02-27 09:40:21 -06:00
parent ad00001049
commit 23c6e5231f
16 changed files with 183 additions and 46 deletions
@@ -22,6 +22,11 @@ const birdseye: SectionConfigOverrides = {
"idle_heartbeat_fps",
],
advancedFields: ["width", "height", "quality", "inactivity_threshold"],
uiSchema: {
mode: {
"ui:size": "xs",
},
},
},
};
@@ -77,7 +77,12 @@ const ffmpeg: SectionConfigOverrides = {
path: {
"ui:options": { size: "full" },
},
global_args: arrayAsTextWidget,
roles: {
"ui:widget": "inputRoles",
},
global_args: {
"ui:widget": "hidden",
},
hwaccel_args: ffmpegArgsWidget("hwaccel_args"),
input_args: ffmpegArgsWidget("input_args"),
output_args: {
@@ -48,23 +48,8 @@ const mqtt: SectionConfigOverrides = {
],
liveValidate: true,
uiSchema: {
host: {
"ui:options": { size: "sm" },
},
topic_prefix: {
"ui:options": { size: "md" },
},
client_id: {
"ui:options": { size: "sm" },
},
tls_ca_certs: {
"ui:options": { size: "md" },
},
tls_client_cert: {
"ui:options": { size: "md" },
},
tls_client_key: {
"ui:options": { size: "md" },
password: {
"ui:options": { size: "xs" },
},
},
},
@@ -7,6 +7,14 @@ const timestampStyle: SectionConfigOverrides = {
fieldOrder: ["position", "format", "color", "thickness"],
hiddenFields: ["effect", "enabled_in_config"],
advancedFields: [],
uiSchema: {
position: {
"ui:size": "xs",
},
format: {
"ui:size": "xs",
},
},
},
};
@@ -128,7 +128,7 @@ export function AdvancedCollapsible({
{label}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4">
<CollapsibleContent className="mt-2 space-y-4 rounded-lg border border-border/60 bg-background_alt/70 p-4">
{children}
</CollapsibleContent>
</Collapsible>
@@ -23,6 +23,7 @@ import { AudioLabelSwitchesWidget } from "./widgets/AudioLabelSwitchesWidget";
import { ZoneSwitchesWidget } from "./widgets/ZoneSwitchesWidget";
import { ArrayAsTextWidget } from "./widgets/ArrayAsTextWidget";
import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
import { InputRolesWidget } from "./widgets/InputRolesWidget";
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
@@ -55,6 +56,7 @@ export const frigateTheme: FrigateTheme = {
CheckboxWidget: SwitchWidget,
ArrayAsTextWidget: ArrayAsTextWidget,
FfmpegArgsWidget: FfmpegArgsWidget,
inputRoles: InputRolesWidget,
// Custom widgets
switch: SwitchWidget,
password: PasswordWidget,
@@ -92,7 +92,8 @@ export function AudioLabelSwitchesWidget(props: WidgetProps) {
getEntities,
getDisplayLabel: getAudioLabelDisplayName,
i18nKey: "audioLabels",
listClassName: "max-h-64 overflow-y-auto scrollbar-container",
listClassName:
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
enableSearch: true,
}}
/>
@@ -3,6 +3,7 @@ import useSWR from "swr";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { ConfigFormContext } from "@/types/configForm";
import {
Select,
SelectContent,
@@ -86,7 +87,17 @@ const normalizeManualText = (value: unknown): string => {
};
export function FfmpegArgsWidget(props: WidgetProps) {
const { t } = useTranslation(["views/settings"]);
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
const { t, i18n } = useTranslation([
effectiveNamespace || i18nNamespace || "common",
i18nNamespace || "common",
"views/settings",
]);
const {
value,
onChange,
@@ -165,6 +176,47 @@ export function FfmpegArgsWidget(props: WidgetProps) {
const manualValue = normalizeManualText(value);
const presetValue =
typeof value === "string" && presetOptions.includes(value) ? value : "";
const fallbackDescriptionKey = useMemo(() => {
if (!presetField) {
return undefined;
}
const isInputScoped = id.includes("_inputs_");
const prefix = isInputScoped ? "ffmpeg.inputs" : "ffmpeg";
if (presetField === "hwaccel_args") {
return `${prefix}.hwaccel_args.description`;
}
if (presetField === "input_args") {
return `${prefix}.input_args.description`;
}
if (presetField === "output_args.record") {
return isInputScoped
? "ffmpeg.inputs.output_args.record.description"
: "ffmpeg.output_args.record.description";
}
if (presetField === "output_args.detect") {
return isInputScoped
? "ffmpeg.inputs.output_args.detect.description"
: "ffmpeg.output_args.detect.description";
}
return undefined;
}, [id, presetField]);
const translatedDescription =
fallbackDescriptionKey &&
effectiveNamespace &&
i18n.exists(fallbackDescriptionKey, { ns: effectiveNamespace })
? t(fallbackDescriptionKey, { ns: effectiveNamespace })
: "";
const fieldDescription =
typeof schema.description === "string" && schema.description.length > 0
? schema.description
: translatedDescription;
return (
<div className="space-y-2">
@@ -244,6 +296,10 @@ export function FfmpegArgsWidget(props: WidgetProps) {
}
/>
)}
{fieldDescription ? (
<p className="text-xs text-muted-foreground">{fieldDescription}</p>
) : null}
</div>
);
}
@@ -0,0 +1,65 @@
import type { WidgetProps } from "@rjsf/utils";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
const INPUT_ROLES = ["detect", "record", "audio"] as const;
function normalizeValue(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
if (typeof value === "string" && value.trim()) {
return [value.trim()];
}
return [];
}
export function InputRolesWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange } = props;
const { t } = useTranslation(["views/settings"]);
const selectedRoles = useMemo(() => normalizeValue(value), [value]);
const toggleRole = (role: string, enabled: boolean) => {
if (enabled) {
if (!selectedRoles.includes(role)) {
onChange([...selectedRoles, role]);
}
return;
}
onChange(selectedRoles.filter((item) => item !== role));
};
return (
<div className="space-y-2 rounded-lg bg-secondary p-2 pr-0 md:max-w-md">
{INPUT_ROLES.map((role) => {
const checked = selectedRoles.includes(role);
const label = t(`configForm.inputRoles.options.${role}`, {
ns: "views/settings",
defaultValue: role,
});
return (
<div
key={role}
className="flex items-center justify-between rounded-md px-3 py-0"
>
<label htmlFor={`${id}-${role}`} className="text-sm">
{label}
</label>
<Switch
id={`${id}-${role}`}
checked={checked}
disabled={disabled || readonly}
onCheckedChange={(enabled) => toggleRole(role, !!enabled)}
/>
</div>
);
})}
</div>
);
}
@@ -93,7 +93,8 @@ export function ObjectLabelSwitchesWidget(props: WidgetProps) {
getEntities: getObjectLabels,
getDisplayLabel: getObjectLabelDisplayName,
i18nKey: "objectLabels",
listClassName: "max-h-64 overflow-y-auto scrollbar-container",
listClassName:
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
}}
/>
);
+1 -1
View File
@@ -222,7 +222,7 @@ export default function UiSettingsView() {
];
return (
<div className="flex size-full flex-col md:flex-row">
<div className="flex size-full flex-col md:flex-row md:pb-8">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<div className="w-full max-w-5xl space-y-6">