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 { useCallback, useMemo } from "react";
import useSWR from "swr";
import { SwitchesWidget } from "./SwitchesWidget";
import type { FormContext } from "./SwitchesWidget";
import { getTranslatedLabel } from "@/utils/i18n";
import { JsonObject } from "@/types/configForm";
function getAudioLabels(context: FormContext): string[] {
function getEnabledAudioLabels(context: FormContext): string[] {
let cameraLabels: string[] = [];
let globalLabels: string[] = [];
if (context) {
// context.cameraValue and context.globalValue should be the entire objects section
const trackValue = context.cameraValue?.listen;
if (Array.isArray(trackValue)) {
cameraLabels = trackValue.filter(
(item): item is string => typeof item === "string",
);
// context.cameraValue and context.globalValue should be the entire audio section
if (
context.cameraValue &&
typeof context.cameraValue === "object" &&
!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 (Array.isArray(globalTrackValue)) {
globalLabels = globalTrackValue.filter(
(item): item is string => typeof item === "string",
);
if (
context.globalValue &&
typeof context.globalValue === "object" &&
!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) {
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 (
<SwitchesWidget
{...props}
options={{
...props.options,
getEntities: getAudioLabels,
getEntities,
getDisplayLabel: getAudioLabelDisplayName,
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 { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Collapsible,
CollapsibleContent,
@ -11,10 +12,16 @@ import {
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { ConfigFormContext } from "@/types/configForm";
import { cn } from "@/lib/utils";
type FormContext = Pick<
ConfigFormContext,
"cameraValue" | "globalValue" | "fullCameraConfig" | "fullConfig" | "t"
| "cameraValue"
| "globalValue"
| "fullCameraConfig"
| "fullConfig"
| "t"
| "level"
> & {
fullCameraConfig?: CameraConfig;
fullConfig?: FrigateConfig;
@ -31,6 +38,10 @@ export type SwitchesWidgetOptions = {
i18nKey: string;
/** Translation namespace (default: "views/settings") */
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[] {
@ -100,8 +111,30 @@ export function SwitchesWidget(props: WidgetProps) {
[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 [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) => {
if (enabled) {
@ -150,28 +183,41 @@ export function SwitchesWidget(props: WidgetProps) {
{availableEntities.length === 0 ? (
<div className="text-sm text-muted-foreground">{emptyMessage}</div>
) : (
<div className="grid gap-2">
{availableEntities.map((entity) => {
const checked = selectedEntities.includes(entity);
const displayLabel = getDisplayLabel(entity, context);
return (
<div
key={entity}
className="flex items-center justify-between rounded-md px-3 py-0"
>
<label htmlFor={`${id}-${entity}`} className="text-sm">
{displayLabel}
</label>
<Switch
id={`${id}-${entity}`}
checked={checked}
disabled={disabled || readonly}
onCheckedChange={(value) => toggleEntity(entity, !!value)}
/>
</div>
);
})}
</div>
<>
{enableSearch && (
<Input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="mb-2"
/>
)}
<div className={cn("grid gap-2", listClassName)}>
{filteredEntities.map((entity) => {
const checked = selectedEntities.includes(entity);
const displayLabel = getDisplayLabel(entity, context);
return (
<div
key={entity}
className="flex items-center justify-between rounded-md px-3 py-0"
>
<label htmlFor={`${id}-${entity}`} className="text-sm">
{displayLabel}
</label>
<Switch
id={`${id}-${entity}`}
checked={checked}
disabled={disabled || readonly}
onCheckedChange={(value) =>
toggleEntity(entity, !!value)
}
/>
</div>
);
})}
</div>
</>
)}
</CollapsibleContent>
</div>