add audio label switches and input to filter list

This commit is contained in:
Josh Hawkins 2026-02-03 09:59:17 -06:00
parent 95d9b01771
commit ed5596d50d
2 changed files with 135 additions and 37 deletions

View File

@ -1,27 +1,42 @@
// Object Label Switches Widget - For selecting objects via switches // Audio Label Switches Widget - For selecting audio labels via switches
import type { WidgetProps } from "@rjsf/utils"; import type { WidgetProps } from "@rjsf/utils";
import { useCallback, useMemo } from "react";
import useSWR from "swr";
import { SwitchesWidget } from "./SwitchesWidget"; import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget"; import type { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { JsonObject } from "@/types/configForm";
function getAudioLabels(context: FormContext): string[] { function getEnabledAudioLabels(context: FormContext): string[] {
let cameraLabels: string[] = []; let cameraLabels: string[] = [];
let globalLabels: string[] = []; let globalLabels: string[] = [];
if (context) { if (context) {
// context.cameraValue and context.globalValue should be the entire objects section // context.cameraValue and context.globalValue should be the entire audio section
const trackValue = context.cameraValue?.listen; if (
if (Array.isArray(trackValue)) { context.cameraValue &&
cameraLabels = trackValue.filter( typeof context.cameraValue === "object" &&
(item): item is string => typeof item === "string", !Array.isArray(context.cameraValue)
); ) {
const listenValue = (context.cameraValue as JsonObject).listen;
if (Array.isArray(listenValue)) {
cameraLabels = listenValue.filter(
(item): item is string => typeof item === "string",
);
}
} }
const globalTrackValue = context.globalValue?.listen; if (
if (Array.isArray(globalTrackValue)) { context.globalValue &&
globalLabels = globalTrackValue.filter( typeof context.globalValue === "object" &&
(item): item is string => typeof item === "string", !Array.isArray(context.globalValue)
); ) {
const globalListenValue = (context.globalValue as JsonObject).listen;
if (Array.isArray(globalListenValue)) {
globalLabels = globalListenValue.filter(
(item): item is string => typeof item === "string",
);
}
} }
} }
@ -34,14 +49,51 @@ function getAudioLabelDisplayName(label: string): string {
} }
export function AudioLabelSwitchesWidget(props: WidgetProps) { export function AudioLabelSwitchesWidget(props: WidgetProps) {
const { data: audioLabels } = useSWR<Record<string, string>>("/audio_labels");
const allLabels = useMemo(() => {
if (!audioLabels) {
return [];
}
const labelSet = new Set<string>();
Object.values(audioLabels).forEach((label) => {
if (typeof label !== "string") {
return;
}
const normalized = label.trim();
if (normalized) {
labelSet.add(normalized);
}
});
return [...labelSet].sort();
}, [audioLabels]);
const getEntities = useCallback(
(context: FormContext) => {
const enabledLabels = getEnabledAudioLabels(context);
if (allLabels.length === 0) {
return enabledLabels;
}
const combinedLabels = new Set([...allLabels, ...enabledLabels]);
return [...combinedLabels].sort();
},
[allLabels],
);
return ( return (
<SwitchesWidget <SwitchesWidget
{...props} {...props}
options={{ options={{
...props.options, ...props.options,
getEntities: getAudioLabels, getEntities,
getDisplayLabel: getAudioLabelDisplayName, getDisplayLabel: getAudioLabelDisplayName,
i18nKey: "audioLabels", i18nKey: "audioLabels",
listClassName: "max-h-64 overflow-y-auto scrollbar-container",
enableSearch: true,
}} }}
/> />
); );

View File

@ -3,6 +3,7 @@ import { WidgetProps } from "@rjsf/utils";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@ -11,10 +12,16 @@ import {
import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { ConfigFormContext } from "@/types/configForm"; import { ConfigFormContext } from "@/types/configForm";
import { cn } from "@/lib/utils";
type FormContext = Pick< type FormContext = Pick<
ConfigFormContext, ConfigFormContext,
"cameraValue" | "globalValue" | "fullCameraConfig" | "fullConfig" | "t" | "cameraValue"
| "globalValue"
| "fullCameraConfig"
| "fullConfig"
| "t"
| "level"
> & { > & {
fullCameraConfig?: CameraConfig; fullCameraConfig?: CameraConfig;
fullConfig?: FrigateConfig; fullConfig?: FrigateConfig;
@ -31,6 +38,10 @@ export type SwitchesWidgetOptions = {
i18nKey: string; i18nKey: string;
/** Translation namespace (default: "views/settings") */ /** Translation namespace (default: "views/settings") */
namespace?: string; namespace?: string;
/** Optional class name for the list container */
listClassName?: string;
/** Enable search input to filter the list */
enableSearch?: boolean;
}; };
function normalizeValue(value: unknown): string[] { function normalizeValue(value: unknown): string[] {
@ -100,8 +111,30 @@ export function SwitchesWidget(props: WidgetProps) {
[props.options], [props.options],
); );
const listClassName = useMemo(
() => props.options?.listClassName as string | undefined,
[props.options],
);
const enableSearch = useMemo(
() => props.options?.enableSearch as boolean | undefined,
[props.options],
);
const selectedEntities = useMemo(() => normalizeValue(value), [value]); const selectedEntities = useMemo(() => normalizeValue(value), [value]);
const [isOpen, setIsOpen] = useState(selectedEntities.length > 0); const [isOpen, setIsOpen] = useState(selectedEntities.length > 0);
const [searchTerm, setSearchTerm] = useState("");
const filteredEntities = useMemo(() => {
if (!enableSearch || !searchTerm.trim()) {
return availableEntities;
}
const term = searchTerm.toLowerCase();
return availableEntities.filter((entity) => {
const displayLabel = getDisplayLabel(entity, context);
return displayLabel.toLowerCase().includes(term);
});
}, [availableEntities, searchTerm, enableSearch, getDisplayLabel, context]);
const toggleEntity = (entity: string, enabled: boolean) => { const toggleEntity = (entity: string, enabled: boolean) => {
if (enabled) { if (enabled) {
@ -150,28 +183,41 @@ export function SwitchesWidget(props: WidgetProps) {
{availableEntities.length === 0 ? ( {availableEntities.length === 0 ? (
<div className="text-sm text-muted-foreground">{emptyMessage}</div> <div className="text-sm text-muted-foreground">{emptyMessage}</div>
) : ( ) : (
<div className="grid gap-2"> <>
{availableEntities.map((entity) => { {enableSearch && (
const checked = selectedEntities.includes(entity); <Input
const displayLabel = getDisplayLabel(entity, context); type="text"
return ( placeholder="Search..."
<div value={searchTerm}
key={entity} onChange={(e) => setSearchTerm(e.target.value)}
className="flex items-center justify-between rounded-md px-3 py-0" className="mb-2"
> />
<label htmlFor={`${id}-${entity}`} className="text-sm"> )}
{displayLabel} <div className={cn("grid gap-2", listClassName)}>
</label> {filteredEntities.map((entity) => {
<Switch const checked = selectedEntities.includes(entity);
id={`${id}-${entity}`} const displayLabel = getDisplayLabel(entity, context);
checked={checked} return (
disabled={disabled || readonly} <div
onCheckedChange={(value) => toggleEntity(entity, !!value)} key={entity}
/> className="flex items-center justify-between rounded-md px-3 py-0"
</div> >
); <label htmlFor={`${id}-${entity}`} className="text-sm">
})} {displayLabel}
</div> </label>
<Switch
id={`${id}-${entity}`}
checked={checked}
disabled={disabled || readonly}
onCheckedChange={(value) =>
toggleEntity(entity, !!value)
}
/>
</div>
);
})}
</div>
</>
)} )}
</CollapsibleContent> </CollapsibleContent>
</div> </div>