diff --git a/web/src/components/config-form/theme/fields/CameraInputsField.tsx b/web/src/components/config-form/theme/fields/CameraInputsField.tsx index ee19dbc95d..205e888c9e 100644 --- a/web/src/components/config-form/theme/fields/CameraInputsField.tsx +++ b/web/src/components/config-form/theme/fields/CameraInputsField.tsx @@ -29,11 +29,19 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { StreamSourceSelector } from "./StreamSourceSelector"; +import { + buildRestreamPath, + parseRestreamStreamName, + RESTREAM_PRESET, + type StreamSourceMode, +} from "./streamSource"; type FfmpegInput = { path?: string; roles?: string[]; hwaccel_args?: unknown; + input_args?: unknown; }; const asInputList = (formData: unknown): FfmpegInput[] => { @@ -137,7 +145,30 @@ export function CameraInputsField(props: FieldProps) { ); const SchemaField = registry.fields.SchemaField; + 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 [openByIndex, setOpenByIndex] = useState>({}); + const [sourceModeByIndex, setSourceModeByIndex] = useState< + Record + >({}); + + // Detect whether an existing input path points at a known go2rtc restream so + // the source toggle can default to the right mode for existing configs. + const detectMode = useCallback( + (path: string | undefined): StreamSourceMode => { + const streamName = parseRestreamStreamName(path); + return streamName && go2rtcStreamNames.includes(streamName) + ? "restream" + : "manual"; + }, + [go2rtcStreamNames], + ); useEffect(() => { setOpenByIndex((previous) => { @@ -171,6 +202,55 @@ export function CameraInputsField(props: FieldProps) { [fieldPathId.path, inputs, onChange], ); + // Update several fields of one input in a single change so that path and + // input_args never race on a stale snapshot of inputs. + const handleFieldValuesChange = useCallback( + (index: number, partial: Record) => { + const nextInputs = cloneDeep(inputs); + const item = + (nextInputs[index] as Record | undefined) ?? + ({} as Record); + + Object.assign(item, partial); + nextInputs[index] = item; + + onChange(normalizeNonDetectHwaccel(nextInputs), fieldPathId.path); + }, + [fieldPathId.path, inputs, onChange], + ); + + const handleSourceModeChange = useCallback( + (index: number, nextMode: StreamSourceMode) => { + const input = inputs[index]; + const currentPath = + typeof input?.path === "string" ? input.path : undefined; + + if (nextMode === "manual") { + // Only revert the preset we set ourselves; never clobber custom args. + if (input?.input_args === RESTREAM_PRESET) { + handleFieldValuesChange(index, { input_args: undefined }); + } + } else if (!parseRestreamStreamName(currentPath)) { + // Entering restream with a non-restream path: clear it so the dropdown + // shows its placeholder until a stream is chosen. + handleFieldValuesChange(index, { path: undefined }); + } + + setSourceModeByIndex((previous) => ({ ...previous, [index]: nextMode })); + }, + [inputs, handleFieldValuesChange], + ); + + const handleSelectRestreamStream = useCallback( + (index: number, streamName: string) => { + handleFieldValuesChange(index, { + path: buildRestreamPath(streamName), + input_args: RESTREAM_PRESET, + }); + }, + [handleFieldValuesChange], + ); + const handleAddInput = useCallback(() => { const base = itemSchema ? (applySchemaDefaults(itemSchema) as FfmpegInput) @@ -186,8 +266,9 @@ export function CameraInputsField(props: FieldProps) { (_, currentIndex) => currentIndex !== index, ); onChange(nextInputs, fieldPathId.path); - setOpenByIndex((previous) => { - const next: Record = {}; + + const reindex = (previous: Record): Record => { + const next: Record = {}; Object.entries(previous).forEach(([key, value]) => { const current = Number(key); if (Number.isNaN(current) || current === index) { @@ -197,7 +278,10 @@ export function CameraInputsField(props: FieldProps) { next[current > index ? current - 1 : current] = value; }); return next; - }); + }; + + setOpenByIndex(reindex); + setSourceModeByIndex(reindex); }, [fieldPathId.path, inputs, onChange], ); @@ -354,16 +438,32 @@ export function CameraInputsField(props: FieldProps) {
- {renderField(index, "path", { - extraUiSchema: { - "ui:widget": "CameraPathWidget", - "ui:options": { - size: "full", - splitLayout: false, + + handleSourceModeChange(index, nextMode) + } + streamNames={go2rtcStreamNames} + selectedStreamName={ + parseRestreamStreamName(input.path) ?? "" + } + onSelectStream={(streamName) => + handleSelectRestreamStream(index, streamName) + } + manualField={renderField(index, "path", { + extraUiSchema: { + "ui:widget": "CameraPathWidget", + "ui:options": { + size: "full", + splitLayout: false, + }, }, - }, - showSchemaDescription: true, - })} + showSchemaDescription: true, + })} + disabled={disabled} + readonly={readonly} + />
{renderField(index, "roles")}
diff --git a/web/src/components/config-form/theme/fields/StreamSourceSelector.tsx b/web/src/components/config-form/theme/fields/StreamSourceSelector.tsx new file mode 100644 index 0000000000..4bcfeac58c --- /dev/null +++ b/web/src/components/config-form/theme/fields/StreamSourceSelector.tsx @@ -0,0 +1,217 @@ +import type { ReactNode } from "react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { Check, ChevronsUpDown } from "lucide-react"; +import type { StreamSourceMode } from "./streamSource"; + +type Go2rtcStreamComboboxProps = { + id: string; + value: string; + options: string[]; + disabled?: boolean; + onSelect: (streamName: string) => void; +}; + +// Searchable dropdown of existing go2rtc streams +function Go2rtcStreamCombobox({ + id, + value, + options, + disabled, + onSelect, +}: Go2rtcStreamComboboxProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + + const commit = (next: string) => { + onSelect(next); + setSearchValue(""); + setOpen(false); + }; + + return ( + { + setOpen(next); + if (!next) setSearchValue(""); + }} + > + + + + + + + + + {t("configForm.cameraInputs.sourceMode.noMatchingStreams")} + + + {options.map((option) => ( + commit(option)} + > + + {option} + + ))} + + + + + + ); +} + +type StreamSourceSelectorProps = { + idPrefix: string; + mode: StreamSourceMode; + onModeChange: (mode: StreamSourceMode) => void; + streamNames: string[]; + selectedStreamName: string; + onSelectStream: (streamName: string) => void; + manualField: ReactNode; + disabled?: boolean; + readonly?: boolean; +}; + +export function StreamSourceSelector({ + idPrefix, + mode, + onModeChange, + streamNames, + selectedStreamName, + onSelectStream, + manualField, + disabled, + readonly, +}: StreamSourceSelectorProps) { + const { t } = useTranslation(["views/settings", "common"]); + + const restreamId = `${idPrefix}-source-restream`; + const manualId = `${idPrefix}-source-manual`; + const selectId = `${idPrefix}-restream-select`; + + const hasStreams = streamNames.length > 0; + const isDisabled = disabled || readonly; + + return ( +
+ onModeChange(value as StreamSourceMode)} + className="flex flex-col gap-2 sm:flex-row sm:gap-6" + disabled={isDisabled} + > +
+ + +
+
+ + +
+
+ + {mode === "restream" ? ( +
+ + {hasStreams ? ( + + ) : ( +

+ {t("configForm.cameraInputs.sourceMode.noGo2rtcStreams")} +

+ )} +
+ ) : ( + manualField + )} +
+ ); +} + +export default StreamSourceSelector; diff --git a/web/src/components/config-form/theme/fields/streamSource.ts b/web/src/components/config-form/theme/fields/streamSource.ts new file mode 100644 index 0000000000..142f2b2ac9 --- /dev/null +++ b/web/src/components/config-form/theme/fields/streamSource.ts @@ -0,0 +1,33 @@ +export type StreamSourceMode = "restream" | "manual"; + +// The literal go2rtc restream prefix matches what the camera wizard inlines +// when it builds a restreamed input path. Only this exact host:port is treated +// as a restream so manually typed URLs (including localhost) stay manual. +export const RESTREAM_PREFIX = "rtsp://127.0.0.1:8554/"; +export const RESTREAM_PRESET = "preset-rtsp-restream"; + +/** Build the restream input path for a given go2rtc stream name. */ +export function buildRestreamPath(streamName: string): string { + return `${RESTREAM_PREFIX}${streamName}`; +} + +/** + * Extract the go2rtc stream name from a restream input path. + * + * Returns the stream name when the path is a well-formed restream URL with no + * extra path segments or query, otherwise undefined. + */ +export function parseRestreamStreamName( + path: string | undefined, +): string | undefined { + if (typeof path !== "string" || !path.startsWith(RESTREAM_PREFIX)) { + return undefined; + } + + const name = path.slice(RESTREAM_PREFIX.length); + if (name.length === 0 || /[/?#]/.test(name)) { + return undefined; + } + + return name; +}