From 50f17e68522419794086ead3752519f9bf966264 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 27 May 2026 14:35:07 -0500 Subject: [PATCH] Add live streams widget (#23330) * add live streams widget * i18n * docs --- docs/docs/configuration/live.md | 14 +- web/public/locales/en/views/settings.json | 11 + .../config-form/section-configs/live.ts | 13 +- .../theme/fields/LiveStreamsField.tsx | 346 ++++++++++++++++++ .../config-form/theme/fields/index.ts | 1 + .../config-form/theme/frigateTheme.ts | 2 + 6 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 web/src/components/config-form/theme/fields/LiveStreamsField.tsx diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index b6c0e288f1..d27447f1bc 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -88,8 +88,18 @@ Configure a "friendly name" for your stream followed by the go2rtc stream name. -1. Navigate to , then select your camera. - - Under **Live stream names**, add entries mapping a friendly name to each go2rtc stream name (e.g., `Main Stream` mapped to `test_cam`, `Sub Stream` mapped to `test_cam_sub`). +1. Navigate to and select your camera. +2. Under **Live stream names**, click **Add stream** to add a new entry. +3. In the **Stream name** field, enter a friendly name that will appear in the Live UI's stream dropdown (e.g., `Main Stream`). +4. In the **go2rtc stream** field, open the dropdown and select the go2rtc stream this name should map to (e.g., `test_cam`). The dropdown lists every stream configured under `go2rtc.streams`. If the go2rtc stream hasn't been created yet, you can type the name and choose **Use "..."** to save a custom value. +5. Repeat for each additional stream you want to expose (e.g., `Sub Stream` → `test_cam_sub`). +6. Use the trash icon on a row to remove a stream, then **Save** the section. + +:::tip + +Configure your go2rtc streams first under so the dropdown is populated with valid options. + +::: diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 9731eb22dc..966f83d725 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1405,6 +1405,17 @@ "namePlaceholder": "e.g., Wife's Car", "platePlaceholder": "Plate number or regex" }, + "liveStreams": { + "streamNameLabel": "Stream name", + "streamNamePlaceholder": "e.g., Main HD Stream", + "go2rtcStreamLabel": "go2rtc stream", + "go2rtcStreamPlaceholder": "Select a go2rtc stream", + "go2rtcStreamSearch": "Search or enter a stream name…", + "noGo2rtcStreams": "No go2rtc streams configured", + "availableStreams": "Available streams", + "useCustom": "Use \"{{value}}\"", + "addStream": "Add stream" + }, "timezone": { "defaultOption": "Use browser timezone" }, diff --git a/web/src/components/config-form/section-configs/live.ts b/web/src/components/config-form/section-configs/live.ts index c0d80627c2..c0026ec8da 100644 --- a/web/src/components/config-form/section-configs/live.ts +++ b/web/src/components/config-form/section-configs/live.ts @@ -4,17 +4,26 @@ const live: SectionConfigOverrides = { base: { sectionDocs: "/configuration/live", restartRequired: [], - fieldOrder: ["stream_name", "height", "quality"], + fieldOrder: ["streams", "height", "quality"], fieldGroups: {}, hiddenFields: ["enabled_in_config"], advancedFields: ["height", "quality"], }, global: { - restartRequired: ["stream_name", "height", "quality"], + restartRequired: ["streams", "height", "quality"], hiddenFields: ["streams"], }, camera: { restartRequired: ["height", "quality"], + uiSchema: { + streams: { + "ui:field": "LiveStreamsField", + "ui:options": { + label: false, + suppressDescription: true, + }, + }, + }, }, }; diff --git a/web/src/components/config-form/theme/fields/LiveStreamsField.tsx b/web/src/components/config-form/theme/fields/LiveStreamsField.tsx new file mode 100644 index 0000000000..b74547ba1b --- /dev/null +++ b/web/src/components/config-form/theme/fields/LiveStreamsField.tsx @@ -0,0 +1,346 @@ +import type { FieldPathList, FieldProps, RJSFSchema } from "@rjsf/utils"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { Check, ChevronsUpDown, Plus } from "lucide-react"; +import { LuPlus, LuTrash2 } from "react-icons/lu"; +import type { ConfigFormContext } from "@/types/configForm"; +import get from "lodash/get"; +import { isSubtreeModified } from "../utils"; + +type LiveStreamsData = Record; + +type StreamValueComboboxProps = { + id: string; + value: string; + options: string[]; + disabled?: boolean; + readonly?: boolean; + onChange: (next: string) => void; +}; + +function StreamValueCombobox({ + id, + value, + options, + disabled, + readonly, + onChange, +}: StreamValueComboboxProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + + const trimmedSearch = searchValue.trim(); + const matchesOption = useMemo( + () => options.some((o) => o.toLowerCase() === trimmedSearch.toLowerCase()), + [options, trimmedSearch], + ); + const showCustomOption = trimmedSearch.length > 0 && !matchesOption; + + const commit = (next: string) => { + onChange(next); + setSearchValue(""); + setOpen(false); + }; + + const placeholder = t("configForm.liveStreams.go2rtcStreamPlaceholder", { + ns: "views/settings", + }); + const searchPlaceholder = t("configForm.liveStreams.go2rtcStreamSearch", { + ns: "views/settings", + }); + const noStreams = t("configForm.liveStreams.noGo2rtcStreams", { + ns: "views/settings", + }); + const availableHeading = t("configForm.liveStreams.availableStreams", { + ns: "views/settings", + }); + + return ( + { + setOpen(next); + if (!next) setSearchValue(""); + }} + > + + + + + + { + if (e.key === "Enter" && showCustomOption) { + e.preventDefault(); + commit(trimmedSearch); + } + }} + /> + + {showCustomOption && ( + + commit(trimmedSearch)} + > + + {t("configForm.liveStreams.useCustom", { + ns: "views/settings", + value: trimmedSearch, + })} + + + )} + {options.length > 0 ? ( + + {options.map((option) => ( + commit(option)} + > + + {option} + + ))} + + ) : !showCustomOption ? ( +
+ {noStreams} +
+ ) : null} +
+
+
+
+ ); +} + +export function LiveStreamsField(props: FieldProps) { + const { schema, formData, onChange, idSchema, disabled, readonly } = props; + const formContext = props.registry?.formContext as + | ConfigFormContext + | undefined; + + const configNamespace = + formContext?.i18nNamespace ?? + (formContext?.level === "camera" ? "config/cameras" : "config/global"); + const { t: fallbackT } = useTranslation(["common", configNamespace]); + const t = formContext?.t ?? fallbackT; + + const data: LiveStreamsData = useMemo(() => { + if (!formData || typeof formData !== "object" || Array.isArray(formData)) { + return {}; + } + return formData as LiveStreamsData; + }, [formData]); + + const entries = useMemo(() => Object.entries(data), [data]); + + const id = idSchema?.$id ?? props.name; + const sectionPrefix = formContext?.sectionI18nPrefix; + + const title = + t(`${sectionPrefix}.${id}.label`) ?? (schema as RJSFSchema).title; + const description = + t(`${sectionPrefix}.${id}.description`) ?? + (schema as RJSFSchema).description; + + const go2rtcStreamNames = useMemo(() => { + const streams = formContext?.fullConfig?.go2rtc?.streams; + if (!streams || typeof streams !== "object") return []; + return Object.keys(streams).sort(); + }, [formContext?.fullConfig?.go2rtc?.streams]); + + const emptyPath = useMemo(() => [] as FieldPathList, []); + const fieldPath = + (props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ?? + emptyPath; + + const isModified = useMemo(() => { + const baselineRoot = formContext?.baselineFormData; + const baselineValue = baselineRoot + ? get(baselineRoot, fieldPath) + : undefined; + return isSubtreeModified( + data, + baselineValue, + formContext?.overrides, + fieldPath, + formContext?.formData, + ); + }, [fieldPath, formContext, data]); + + const handleAddEntry = useCallback(() => { + const next = { ...data, "": "" }; + onChange(next, fieldPath); + }, [data, fieldPath, onChange]); + + const handleRemoveEntry = useCallback( + (key: string) => { + const next = { ...data }; + delete next[key]; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleRenameKey = useCallback( + (oldKey: string, newKey: string) => { + if (oldKey === newKey) return; + const next: LiveStreamsData = {}; + for (const [k, v] of Object.entries(data)) { + if (k === oldKey) { + next[newKey] = v; + } else { + next[k] = v; + } + } + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const handleUpdateValue = useCallback( + (key: string, value: string) => { + const next = { ...data, [key]: value }; + onChange(next, fieldPath); + }, + [data, fieldPath, onChange], + ); + + const baseId = idSchema?.$id || "live_streams"; + const deleteLabel = t("button.delete", { + ns: "common", + defaultValue: "Delete", + }); + const streamNameLabel = t("configForm.liveStreams.streamNameLabel", { + ns: "views/settings", + }); + const streamNamePlaceholder = t( + "configForm.liveStreams.streamNamePlaceholder", + { ns: "views/settings" }, + ); + const go2rtcStreamLabel = t("configForm.liveStreams.go2rtcStreamLabel", { + ns: "views/settings", + }); + const addStreamLabel = t("configForm.liveStreams.addStream", { + ns: "views/settings", + }); + + return ( + + + + {title} + + {description && ( +

{description}

+ )} +
+ + {entries.map(([key, value], entryIndex) => { + const entryId = `${baseId}-${entryIndex}`; + return ( +
+
+ + handleRenameKey(key, e.target.value)} + /> +
+
+ + handleUpdateValue(key, next)} + /> +
+
+ +
+
+ ); + })} + +
+ +
+
+
+ ); +} + +export default LiveStreamsField; diff --git a/web/src/components/config-form/theme/fields/index.ts b/web/src/components/config-form/theme/fields/index.ts index b6b7078661..4bb91ed352 100644 --- a/web/src/components/config-form/theme/fields/index.ts +++ b/web/src/components/config-form/theme/fields/index.ts @@ -2,3 +2,4 @@ export { LayoutGridField } from "./LayoutGridField"; export { DetectorHardwareField } from "./DetectorHardwareField"; export { ReplaceRulesField } from "./ReplaceRulesField"; +export { LiveStreamsField } from "./LiveStreamsField"; diff --git a/web/src/components/config-form/theme/frigateTheme.ts b/web/src/components/config-form/theme/frigateTheme.ts index ebc6b19b35..40f3f76c93 100644 --- a/web/src/components/config-form/theme/frigateTheme.ts +++ b/web/src/components/config-form/theme/frigateTheme.ts @@ -51,6 +51,7 @@ import { ReplaceRulesField } from "./fields/ReplaceRulesField"; import { CameraInputsField } from "./fields/CameraInputsField"; import { DictAsYamlField } from "./fields/DictAsYamlField"; import { KnownPlatesField } from "./fields/KnownPlatesField"; +import { LiveStreamsField } from "./fields/LiveStreamsField"; export interface FrigateTheme { widgets: RegistryWidgetsType; @@ -109,5 +110,6 @@ export const frigateTheme: FrigateTheme = { CameraInputsField: CameraInputsField, DictAsYamlField: DictAsYamlField, KnownPlatesField: KnownPlatesField, + LiveStreamsField: LiveStreamsField, }, };