Frontend updates (#22327)

* add optional field widget

adds a switch to enable nullable fields like skip_motion_threshold

* config field updates

add skip_motion_threshold optional switch
add fps back to detect restart required

* don't use ternary operator when displaying motion previews

the main previews were being unnecessarily unmounted

* lazy mount motion preview clips to reduce DOM overhead
This commit is contained in:
Josh Hawkins 2026-03-08 12:27:53 -05:00 committed by GitHub
parent ef07563d0a
commit df27e04c0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 279 additions and 130 deletions

View File

@ -30,10 +30,22 @@ const detect: SectionConfigOverrides = {
], ],
}, },
global: { global: {
restartRequired: ["width", "height", "min_initialized", "max_disappeared"], restartRequired: [
"fps",
"width",
"height",
"min_initialized",
"max_disappeared",
],
}, },
camera: { camera: {
restartRequired: ["width", "height", "min_initialized", "max_disappeared"], restartRequired: [
"fps",
"width",
"height",
"min_initialized",
"max_disappeared",
],
}, },
}; };

View File

@ -3,6 +3,12 @@ import type { SectionConfigOverrides } from "./types";
const motion: SectionConfigOverrides = { const motion: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/motion_detection", sectionDocs: "/configuration/motion_detection",
fieldDocs: {
lightning_threshold:
"/configuration/motion_detection#lightning_threshold",
skip_motion_threshold:
"/configuration/motion_detection#skip_motion_on_large_scene_changes",
},
restartRequired: [], restartRequired: [],
fieldOrder: [ fieldOrder: [
"enabled", "enabled",
@ -20,6 +26,16 @@ const motion: SectionConfigOverrides = {
sensitivity: ["enabled", "threshold", "contour_area"], sensitivity: ["enabled", "threshold", "contour_area"],
algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"], algorithm: ["improve_contrast", "delta_alpha", "frame_alpha"],
}, },
uiSchema: {
skip_motion_threshold: {
"ui:widget": "optionalField",
"ui:options": {
innerWidget: "range",
step: 0.05,
suppressMultiSchema: true,
},
},
},
hiddenFields: ["enabled_in_config", "mask", "raw_mask"], hiddenFields: ["enabled_in_config", "mask", "raw_mask"],
advancedFields: [ advancedFields: [
"lightning_threshold", "lightning_threshold",
@ -58,7 +74,7 @@ const motion: SectionConfigOverrides = {
"frame_alpha", "frame_alpha",
"frame_height", "frame_height",
], ],
advancedFields: ["lightning_threshold"], advancedFields: ["lightning_threshold", "skip_motion_threshold"],
}, },
}; };

View File

@ -26,6 +26,7 @@ import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
import { InputRolesWidget } from "./widgets/InputRolesWidget"; import { InputRolesWidget } from "./widgets/InputRolesWidget";
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget"; import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
import { CameraPathWidget } from "./widgets/CameraPathWidget"; import { CameraPathWidget } from "./widgets/CameraPathWidget";
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
import { FieldTemplate } from "./templates/FieldTemplate"; import { FieldTemplate } from "./templates/FieldTemplate";
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate"; import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
@ -73,6 +74,7 @@ export const frigateTheme: FrigateTheme = {
audioLabels: AudioLabelSwitchesWidget, audioLabels: AudioLabelSwitchesWidget,
zoneNames: ZoneSwitchesWidget, zoneNames: ZoneSwitchesWidget,
timezoneSelect: TimezoneSelectWidget, timezoneSelect: TimezoneSelectWidget,
optionalField: OptionalFieldWidget,
}, },
templates: { templates: {
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>, FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,

View File

@ -0,0 +1,64 @@
// Optional Field Widget - wraps any inner widget with an enable/disable switch
// Used for nullable fields where None means "disabled" (not the same as 0)
import type { WidgetProps } from "@rjsf/utils";
import { getWidget } from "@rjsf/utils";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { getNonNullSchema } from "../fields/nullableUtils";
export function OptionalFieldWidget(props: WidgetProps) {
const { id, value, disabled, readonly, onChange, schema, options, registry } =
props;
const innerWidgetName = (options.innerWidget as string) || undefined;
const isEnabled = value !== undefined && value !== null;
// Extract the non-null branch from anyOf [Type, null]
const innerSchema = getNonNullSchema(schema) ?? schema;
const InnerWidget = getWidget(innerSchema, innerWidgetName, registry.widgets);
const getDefaultValue = () => {
if (innerSchema.default !== undefined && innerSchema.default !== null) {
return innerSchema.default;
}
if (innerSchema.minimum !== undefined) {
return innerSchema.minimum;
}
if (innerSchema.type === "integer" || innerSchema.type === "number") {
return 0;
}
if (innerSchema.type === "string") {
return "";
}
return 0;
};
const handleToggle = (checked: boolean) => {
onChange(checked ? getDefaultValue() : undefined);
};
const innerProps: WidgetProps = {
...props,
schema: innerSchema,
disabled: disabled || readonly || !isEnabled,
value: isEnabled ? value : getDefaultValue(),
};
return (
<div className="flex items-center gap-3">
<Switch
id={`${id}-toggle`}
checked={isEnabled}
disabled={disabled || readonly}
onCheckedChange={handleToggle}
/>
<div
className={cn("flex-1", !isEnabled && "pointer-events-none opacity-40")}
>
<InnerWidget {...innerProps} />
</div>
</div>
);
}

View File

@ -1284,7 +1284,7 @@ function MotionReview({
return ( return (
<> <>
{motionPreviewsCamera && selectedMotionPreviewCamera ? ( {selectedMotionPreviewCamera && (
<> <>
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3"> <div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3">
<Button <Button
@ -1465,10 +1465,15 @@ function MotionReview({
}} }}
/> />
</> </>
) : ( )}
<div className="no-scrollbar flex min-w-0 flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
<div <div
ref={contentRef} className={cn(
"no-scrollbar flex min-w-0 flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4",
selectedMotionPreviewCamera && "hidden",
)}
>
<div
ref={selectedMotionPreviewCamera ? undefined : contentRef}
className={cn( className={cn(
"no-scrollbar grid w-full grid-cols-1", "no-scrollbar grid w-full grid-cols-1",
isMobile && "landscape:grid-cols-2", isMobile && "landscape:grid-cols-2",
@ -1562,7 +1567,6 @@ function MotionReview({
})} })}
</div> </div>
</div> </div>
)}
{!selectedMotionPreviewCamera && ( {!selectedMotionPreviewCamera && (
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]"> <div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
{motionData ? ( {motionData ? (

View File

@ -624,6 +624,9 @@ export default function MotionPreviewsPane({
const [hasVisibilityData, setHasVisibilityData] = useState(false); const [hasVisibilityData, setHasVisibilityData] = useState(false);
const clipObserver = useRef<IntersectionObserver | null>(null); const clipObserver = useRef<IntersectionObserver | null>(null);
const [mountedClips, setMountedClips] = useState<Set<string>>(new Set());
const mountObserver = useRef<IntersectionObserver | null>(null);
const recordingTimeRange = useMemo(() => { const recordingTimeRange = useMemo(() => {
if (!motionRanges.length) { if (!motionRanges.length) {
return null; return null;
@ -788,15 +791,56 @@ export default function MotionPreviewsPane({
}; };
}, [scrollContainer]); }, [scrollContainer]);
useEffect(() => {
if (!scrollContainer) {
return;
}
const nearClipIds = new Set<string>();
mountObserver.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const clipId = (entry.target as HTMLElement).dataset.clipId;
if (!clipId) {
return;
}
if (entry.isIntersecting) {
nearClipIds.add(clipId);
} else {
nearClipIds.delete(clipId);
}
});
setMountedClips(new Set(nearClipIds));
},
{
root: scrollContainer,
rootMargin: "200% 0px",
threshold: 0,
},
);
scrollContainer
.querySelectorAll<HTMLElement>("[data-clip-id]")
.forEach((node) => {
mountObserver.current?.observe(node);
});
return () => {
mountObserver.current?.disconnect();
};
}, [scrollContainer]);
const clipRef = useCallback((node: HTMLElement | null) => { const clipRef = useCallback((node: HTMLElement | null) => {
if (!clipObserver.current) { if (!node) {
return; return;
} }
try { try {
if (node) { clipObserver.current?.observe(node);
clipObserver.current.observe(node); mountObserver.current?.observe(node);
}
} catch { } catch {
// no op // no op
} }
@ -864,12 +908,17 @@ export default function MotionPreviewsPane({
) : ( ) : (
<div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-2 pb-2 sm:grid-cols-2 md:gap-4 xl:grid-cols-4">
{clipData.map( {clipData.map(
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => ( ({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => {
const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`;
const isMounted = mountedClips.has(clipId);
return (
<div <div
key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`} key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`}
data-clip-id={`${camera.name}-${range.start_time}-${range.end_time}-${idx}`} data-clip-id={clipId}
ref={clipRef} ref={clipRef}
> >
{isMounted ? (
<MotionPreviewClip <MotionPreviewClip
cameraName={camera.name} cameraName={camera.name}
range={range} range={range}
@ -880,15 +929,17 @@ export default function MotionPreviewsPane({
nonMotionAlpha={nonMotionAlpha} nonMotionAlpha={nonMotionAlpha}
isVisible={ isVisible={
windowVisible && windowVisible &&
(visibleClips.includes( (visibleClips.includes(clipId) ||
`${camera.name}-${range.start_time}-${range.end_time}-${idx}`,
) ||
(!hasVisibilityData && idx < 8)) (!hasVisibilityData && idx < 8))
} }
onSeek={onSeek} onSeek={onSeek}
/> />
) : (
<div className="aspect-video rounded-lg bg-black md:rounded-2xl" />
)}
</div> </div>
), );
},
)} )}
</div> </div>
)} )}