mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 02:29:19 +03:00
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:
parent
ef07563d0a
commit
df27e04c0f
@ -30,10 +30,22 @@ const detect: SectionConfigOverrides = {
|
||||
],
|
||||
},
|
||||
global: {
|
||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
||||
restartRequired: [
|
||||
"fps",
|
||||
"width",
|
||||
"height",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
restartRequired: ["width", "height", "min_initialized", "max_disappeared"],
|
||||
restartRequired: [
|
||||
"fps",
|
||||
"width",
|
||||
"height",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -3,6 +3,12 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const motion: SectionConfigOverrides = {
|
||||
base: {
|
||||
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: [],
|
||||
fieldOrder: [
|
||||
"enabled",
|
||||
@ -20,6 +26,16 @@ const motion: SectionConfigOverrides = {
|
||||
sensitivity: ["enabled", "threshold", "contour_area"],
|
||||
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"],
|
||||
advancedFields: [
|
||||
"lightning_threshold",
|
||||
@ -58,7 +74,7 @@ const motion: SectionConfigOverrides = {
|
||||
"frame_alpha",
|
||||
"frame_height",
|
||||
],
|
||||
advancedFields: ["lightning_threshold"],
|
||||
advancedFields: ["lightning_threshold", "skip_motion_threshold"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ import { FfmpegArgsWidget } from "./widgets/FfmpegArgsWidget";
|
||||
import { InputRolesWidget } from "./widgets/InputRolesWidget";
|
||||
import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
|
||||
import { CameraPathWidget } from "./widgets/CameraPathWidget";
|
||||
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
|
||||
|
||||
import { FieldTemplate } from "./templates/FieldTemplate";
|
||||
import { ObjectFieldTemplate } from "./templates/ObjectFieldTemplate";
|
||||
@ -73,6 +74,7 @@ export const frigateTheme: FrigateTheme = {
|
||||
audioLabels: AudioLabelSwitchesWidget,
|
||||
zoneNames: ZoneSwitchesWidget,
|
||||
timezoneSelect: TimezoneSelectWidget,
|
||||
optionalField: OptionalFieldWidget,
|
||||
},
|
||||
templates: {
|
||||
FieldTemplate: FieldTemplate as React.ComponentType<FieldTemplateProps>,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1284,7 +1284,7 @@ function MotionReview({
|
||||
|
||||
return (
|
||||
<>
|
||||
{motionPreviewsCamera && selectedMotionPreviewCamera ? (
|
||||
{selectedMotionPreviewCamera && (
|
||||
<>
|
||||
<div className="relative mb-2 flex h-11 items-center justify-between pl-2 pr-2 md:px-3">
|
||||
<Button
|
||||
@ -1465,104 +1465,108 @@ 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
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
"no-scrollbar grid w-full grid-cols-1",
|
||||
isMobile && "landscape:grid-cols-2",
|
||||
reviewCameras.length > 3 &&
|
||||
isMobile &&
|
||||
"portrait:md:grid-cols-2 landscape:md:grid-cols-3",
|
||||
isDesktop && "grid-cols-2 lg:grid-cols-3",
|
||||
"gap-2 overflow-auto px-1 md:mx-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{reviewCameras.map((camera) => {
|
||||
let grow;
|
||||
let spans;
|
||||
const aspectRatio = camera.detect.width / camera.detect.height;
|
||||
if (aspectRatio > 2) {
|
||||
grow = "aspect-wide";
|
||||
spans = "sm:col-span-2";
|
||||
} else if (aspectRatio < 1) {
|
||||
grow = "h-full aspect-tall";
|
||||
spans = "md:row-span-2";
|
||||
} else {
|
||||
grow = "aspect-video";
|
||||
}
|
||||
const detectionType = getDetectionType(camera.name);
|
||||
return (
|
||||
<div key={camera.name} className={`relative ${spans}`}>
|
||||
{motionData ? (
|
||||
<>
|
||||
<PreviewPlayer
|
||||
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
||||
camera={camera.name}
|
||||
timeRange={currentTimeRange}
|
||||
startTime={previewStart}
|
||||
cameraPreviews={relevantPreviews}
|
||||
isScrubbing={scrubbing}
|
||||
onControllerReady={(controller) => {
|
||||
videoPlayersRef.current[camera.name] = controller;
|
||||
}}
|
||||
onClick={() =>
|
||||
onOpenRecording({
|
||||
camera: camera.name,
|
||||
startTime: Math.min(
|
||||
currentTime,
|
||||
Date.now() / 1000 - 30,
|
||||
),
|
||||
severity: "significant_motion",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={`review-item-ring pointer-events-none absolute inset-0 z-20 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 z-30">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<BlurredIconButton
|
||||
aria-label={t("motionSearch.openMenu")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FiMoreVertical className="size-5" />
|
||||
</BlurredIconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMotionPreviewsCamera(camera.name);
|
||||
}}
|
||||
>
|
||||
{t("motionPreviews.menuItem")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMotionSearchCamera(camera.name);
|
||||
}}
|
||||
>
|
||||
{t("motionSearch.menuItem")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton
|
||||
className={`size-full rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
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(
|
||||
"no-scrollbar grid w-full grid-cols-1",
|
||||
isMobile && "landscape:grid-cols-2",
|
||||
reviewCameras.length > 3 &&
|
||||
isMobile &&
|
||||
"portrait:md:grid-cols-2 landscape:md:grid-cols-3",
|
||||
isDesktop && "grid-cols-2 lg:grid-cols-3",
|
||||
"gap-2 overflow-auto px-1 md:mx-2 md:gap-4 xl:grid-cols-3 3xl:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{reviewCameras.map((camera) => {
|
||||
let grow;
|
||||
let spans;
|
||||
const aspectRatio = camera.detect.width / camera.detect.height;
|
||||
if (aspectRatio > 2) {
|
||||
grow = "aspect-wide";
|
||||
spans = "sm:col-span-2";
|
||||
} else if (aspectRatio < 1) {
|
||||
grow = "h-full aspect-tall";
|
||||
spans = "md:row-span-2";
|
||||
} else {
|
||||
grow = "aspect-video";
|
||||
}
|
||||
const detectionType = getDetectionType(camera.name);
|
||||
return (
|
||||
<div key={camera.name} className={`relative ${spans}`}>
|
||||
{motionData ? (
|
||||
<>
|
||||
<PreviewPlayer
|
||||
className={`rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
||||
camera={camera.name}
|
||||
timeRange={currentTimeRange}
|
||||
startTime={previewStart}
|
||||
cameraPreviews={relevantPreviews}
|
||||
isScrubbing={scrubbing}
|
||||
onControllerReady={(controller) => {
|
||||
videoPlayersRef.current[camera.name] = controller;
|
||||
}}
|
||||
onClick={() =>
|
||||
onOpenRecording({
|
||||
camera: camera.name,
|
||||
startTime: Math.min(
|
||||
currentTime,
|
||||
Date.now() / 1000 - 30,
|
||||
),
|
||||
severity: "significant_motion",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={`review-item-ring pointer-events-none absolute inset-0 z-20 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 z-30">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<BlurredIconButton
|
||||
aria-label={t("motionSearch.openMenu")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<FiMoreVertical className="size-5" />
|
||||
</BlurredIconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMotionPreviewsCamera(camera.name);
|
||||
}}
|
||||
>
|
||||
{t("motionPreviews.menuItem")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMotionSearchCamera(camera.name);
|
||||
}}
|
||||
>
|
||||
{t("motionSearch.menuItem")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton
|
||||
className={`size-full rounded-lg md:rounded-2xl ${spans} ${grow}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{!selectedMotionPreviewCamera && (
|
||||
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
|
||||
{motionData ? (
|
||||
|
||||
@ -624,6 +624,9 @@ export default function MotionPreviewsPane({
|
||||
const [hasVisibilityData, setHasVisibilityData] = useState(false);
|
||||
const clipObserver = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
const [mountedClips, setMountedClips] = useState<Set<string>>(new Set());
|
||||
const mountObserver = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
const recordingTimeRange = useMemo(() => {
|
||||
if (!motionRanges.length) {
|
||||
return null;
|
||||
@ -788,15 +791,56 @@ export default function MotionPreviewsPane({
|
||||
};
|
||||
}, [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) => {
|
||||
if (!clipObserver.current) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (node) {
|
||||
clipObserver.current.observe(node);
|
||||
}
|
||||
clipObserver.current?.observe(node);
|
||||
mountObserver.current?.observe(node);
|
||||
} catch {
|
||||
// no op
|
||||
}
|
||||
@ -864,31 +908,38 @@ 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">
|
||||
{clipData.map(
|
||||
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => (
|
||||
<div
|
||||
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}`}
|
||||
ref={clipRef}
|
||||
>
|
||||
<MotionPreviewClip
|
||||
cameraName={camera.name}
|
||||
range={range}
|
||||
playbackRate={playbackRate}
|
||||
preview={preview}
|
||||
fallbackFrameTimes={fallbackFrameTimes}
|
||||
motionHeatmap={motionHeatmap}
|
||||
nonMotionAlpha={nonMotionAlpha}
|
||||
isVisible={
|
||||
windowVisible &&
|
||||
(visibleClips.includes(
|
||||
`${camera.name}-${range.start_time}-${range.end_time}-${idx}`,
|
||||
) ||
|
||||
(!hasVisibilityData && idx < 8))
|
||||
}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
({ range, preview, fallbackFrameTimes, motionHeatmap }, idx) => {
|
||||
const clipId = `${camera.name}-${range.start_time}-${range.end_time}-${idx}`;
|
||||
const isMounted = mountedClips.has(clipId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${camera.name}-${range.start_time}-${range.end_time}-${preview?.src ?? "none"}-${idx}`}
|
||||
data-clip-id={clipId}
|
||||
ref={clipRef}
|
||||
>
|
||||
{isMounted ? (
|
||||
<MotionPreviewClip
|
||||
cameraName={camera.name}
|
||||
range={range}
|
||||
playbackRate={playbackRate}
|
||||
preview={preview}
|
||||
fallbackFrameTimes={fallbackFrameTimes}
|
||||
motionHeatmap={motionHeatmap}
|
||||
nonMotionAlpha={nonMotionAlpha}
|
||||
isVisible={
|
||||
windowVisible &&
|
||||
(visibleClips.includes(clipId) ||
|
||||
(!hasVisibilityData && idx < 8))
|
||||
}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-video rounded-lg bg-black md:rounded-2xl" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user