From e2bfa26719fee8410357346730706fef41d15398 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:33:42 -0500 Subject: [PATCH] Add go2rtc streams to settings UI (#22531) * Add go2rtc settings section - create separate settings section for all go2rtc streams - extract credentials mask code into util - create ffmpeg module utility - i18n * add camera config updater topic for live section to support adding go2rtc streams after configuring a new one via the UI * clean up * tweak delete button color for consistency * tweaks --- frigate/config/camera/updater.py | 3 + web/public/locales/en/views/settings.json | 45 +- .../theme/widgets/CameraPathWidget.tsx | 46 +- web/src/pages/Settings.tsx | 7 + web/src/types/frigateConfig.ts | 2 +- web/src/utils/credentialMask.ts | 40 + web/src/utils/go2rtcFfmpeg.ts | 137 +++ .../settings/Go2RtcStreamsSettingsView.tsx | 1009 +++++++++++++++++ 8 files changed, 1246 insertions(+), 43 deletions(-) create mode 100644 web/src/utils/credentialMask.ts create mode 100644 web/src/utils/go2rtcFfmpeg.ts create mode 100644 web/src/views/settings/Go2RtcStreamsSettingsView.tsx diff --git a/frigate/config/camera/updater.py b/frigate/config/camera/updater.py index 261631212..a55f355fb 100644 --- a/frigate/config/camera/updater.py +++ b/frigate/config/camera/updater.py @@ -18,6 +18,7 @@ class CameraConfigUpdateEnum(str, Enum): detect = "detect" enabled = "enabled" ffmpeg = "ffmpeg" + live = "live" motion = "motion" # includes motion and motion masks notifications = "notifications" objects = "objects" @@ -107,6 +108,8 @@ class CameraConfigUpdateSubscriber: config.enabled = updated_config elif update_type == CameraConfigUpdateEnum.object_genai: config.objects.genai = updated_config + elif update_type == CameraConfigUpdateEnum.live: + config.live = updated_config elif update_type == CameraConfigUpdateEnum.motion: config.motion = updated_config elif update_type == CameraConfigUpdateEnum.notifications: diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 745606ba2..a0a674735 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -55,6 +55,7 @@ "systemDetectorHardware": "Detector hardware", "systemDetectionModel": "Detection model", "systemMqtt": "MQTT", + "systemGo2rtcStreams": "go2rtc streams", "integrationSemanticSearch": "Semantic search", "integrationGenerativeAi": "Generative AI", "integrationFaceRecognition": "Face recognition", @@ -1492,5 +1493,47 @@ "unsavedChanges": "You have unsaved changes", "confirmReset": "Confirm Reset", "resetToDefaultDescription": "This will reset all settings in this section to their default values. This action cannot be undone.", - "resetToGlobalDescription": "This will reset the settings in this section to the global defaults. This action cannot be undone." + "resetToGlobalDescription": "This will reset the settings in this section to the global defaults. This action cannot be undone.", + "go2rtcStreams": { + "title": "go2rtc Streams", + "description": "Manage go2rtc stream configurations for camera restreaming. Each stream has a name and one or more source URLs.", + "addStream": "Add stream", + "addStreamDesc": "Enter a name for the new stream. This name will be used to reference the stream in your camera configuration.", + "addUrl": "Add URL", + "streamName": "Stream name", + "streamNamePlaceholder": "e.g., front_door", + "streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream", + "deleteStream": "Delete stream", + "deleteStreamConfirm": "Are you sure you want to delete the stream \"{{streamName}}\"? Cameras that reference this stream may stop working.", + "noStreams": "No go2rtc streams configured. Add a stream to get started.", + "validation": { + "nameRequired": "Stream name is required", + "nameDuplicate": "A stream with this name already exists", + "nameInvalid": "Stream name can only contain letters, numbers, underscores, and hyphens", + "urlRequired": "At least one URL is required" + }, + "renameStream": "Rename stream", + "renameStreamDesc": "Enter a new name for this stream. Renaming a stream may break cameras or other streams that reference it by name.", + "newStreamName": "New stream name", + "ffmpeg": { + "useFfmpegModule": "Use compatibility mode (ffmpeg)", + "video": "Video", + "audio": "Audio", + "hardware": "Hardware acceleration", + "videoCopy": "Copy", + "videoH264": "Transcode to H.264", + "videoH265": "Transcode to H.265", + "videoExclude": "Exclude", + "audioCopy": "Copy", + "audioAac": "Transcode to AAC", + "audioOpus": "Transcode to Opus", + "audioPcmu": "Transcode to PCM μ-law", + "audioPcma": "Transcode to PCM A-law", + "audioPcm": "Transcode to PCM", + "audioMp3": "Transcode to MP3", + "audioExclude": "Exclude", + "hardwareNone": "No hardware acceleration", + "hardwareAuto": "Automatic hardware acceleration" + } + } } diff --git a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx index b2490d2ab..31c52296d 100644 --- a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx +++ b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx @@ -8,6 +8,11 @@ import { Input } from "@/components/ui/input"; import type { ConfigFormContext } from "@/types/configForm"; import { cn } from "@/lib/utils"; import { getSizedFieldClassName } from "../utils"; +import { + isMaskedPath, + hasCredentials, + maskCredentials, +} from "@/utils/credentialMask"; type RawPathsResponse = { cameras?: Record< @@ -22,9 +27,6 @@ type RawPathsResponse = { >; }; -const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i; -const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i; - const getInputIndexFromWidgetId = (id: string): number | undefined => { const match = id.match(/_inputs_(\d+)_path$/); if (!match) { @@ -35,44 +37,6 @@ const getInputIndexFromWidgetId = (id: string): number | undefined => { return Number.isNaN(index) ? undefined : index; }; -const isMaskedPath = (value: string): boolean => - MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value); - -const hasCredentials = (value: string): boolean => { - if (!value) { - return false; - } - - if (isMaskedPath(value)) { - return true; - } - - try { - const parsed = new URL(value); - if (parsed.username || parsed.password) { - return true; - } - - return ( - parsed.searchParams.has("user") && parsed.searchParams.has("password") - ); - } catch { - return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value); - } -}; - -const maskCredentials = (value: string): string => { - if (!value) { - return value; - } - - const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@"); - - return maskedAuth - .replace(/([?&]user=)[^&]*/gi, "$1*") - .replace(/([?&]password=)[^&]*/gi, "$1*"); -}; - export function CameraPathWidget(props: WidgetProps) { const { id, diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 22f25855b..d3c8f4e7b 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -47,6 +47,7 @@ import ProfilesView from "@/views/settings/ProfilesView"; import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView"; import MediaSyncSettingsView from "@/views/settings/MediaSyncSettingsView"; import RegionGridSettingsView from "@/views/settings/RegionGridSettingsView"; +import Go2RtcStreamsSettingsView from "@/views/settings/Go2RtcStreamsSettingsView"; import SystemDetectionModelSettingsView from "@/views/settings/SystemDetectionModelSettingsView"; import { SingleSectionPage, @@ -132,6 +133,7 @@ const allSettingsViews = [ "systemDetectorHardware", "systemDetectionModel", "systemMqtt", + "systemGo2rtcStreams", "integrationSemanticSearch", "integrationGenerativeAi", "integrationFaceRecognition", @@ -414,6 +416,10 @@ const settingsGroups = [ { label: "system", items: [ + { + key: "systemGo2rtcStreams", + component: Go2RtcStreamsSettingsView, + }, { key: "systemDetectorHardware", component: SystemDetectorHardwareSettingsPage, @@ -562,6 +568,7 @@ const ENRICHMENTS_SECTION_MAPPING: Record = { }; const SYSTEM_SECTION_MAPPING: Record = { + go2rtc_streams: "systemGo2rtcStreams", database: "systemDatabase", mqtt: "systemMqtt", tls: "systemTls", diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 1efa47fbc..f844d1a02 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -486,7 +486,7 @@ export interface FrigateConfig { }; go2rtc: { - streams: string[]; + streams: Record; webrtc: { candidates: string[]; }; diff --git a/web/src/utils/credentialMask.ts b/web/src/utils/credentialMask.ts new file mode 100644 index 000000000..34f0996a9 --- /dev/null +++ b/web/src/utils/credentialMask.ts @@ -0,0 +1,40 @@ +const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i; +const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i; + +export const isMaskedPath = (value: string): boolean => + MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value); + +export const hasCredentials = (value: string): boolean => { + if (!value) { + return false; + } + + if (isMaskedPath(value)) { + return true; + } + + try { + const parsed = new URL(value); + if (parsed.username || parsed.password) { + return true; + } + + return ( + parsed.searchParams.has("user") && parsed.searchParams.has("password") + ); + } catch { + return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value); + } +}; + +export const maskCredentials = (value: string): string => { + if (!value) { + return value; + } + + const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@"); + + return maskedAuth + .replace(/([?&]user=)[^&]*/gi, "$1*") + .replace(/([?&]password=)[^&]*/gi, "$1*"); +}; diff --git a/web/src/utils/go2rtcFfmpeg.ts b/web/src/utils/go2rtcFfmpeg.ts new file mode 100644 index 000000000..76f156993 --- /dev/null +++ b/web/src/utils/go2rtcFfmpeg.ts @@ -0,0 +1,137 @@ +export type FfmpegVideoOption = "copy" | "h264" | "h265" | "exclude"; +export type FfmpegAudioOption = + | "copy" + | "aac" + | "opus" + | "pcmu" + | "pcma" + | "pcm" + | "mp3" + | "exclude"; +export type FfmpegHardwareOption = "none" | "auto"; + +export type ParsedFfmpegUrl = { + isFfmpeg: boolean; + baseUrl: string; + video: FfmpegVideoOption; + audio: FfmpegAudioOption; + hardware: FfmpegHardwareOption; + extraFragments: string[]; +}; + +const VIDEO_VALUES = new Set(["copy", "h264", "h265"]); +const AUDIO_VALUES = new Set([ + "copy", + "aac", + "opus", + "pcmu", + "pcma", + "pcm", + "mp3", +]); +const HARDWARE_SPECIFIC = new Set([ + "vaapi", + "cuda", + "v4l2m2m", + "dxva2", + "videotoolbox", +]); + +export function parseFfmpegUrl(url: string): ParsedFfmpegUrl { + if (!url.startsWith("ffmpeg:")) { + return { + isFfmpeg: false, + baseUrl: url, + video: "copy", + audio: "copy", + hardware: "none", + extraFragments: [], + }; + } + + const withoutPrefix = url.slice(7); + const parts = withoutPrefix.split("#"); + const baseUrl = parts[0]; + const fragments = parts.slice(1); + + let video: FfmpegVideoOption | null = null; + let audio: FfmpegAudioOption | null = null; + let hardware: FfmpegHardwareOption = "none"; + const extraFragments: string[] = []; + + for (const frag of fragments) { + if (frag.startsWith("video=")) { + const val = frag.slice(6); + if (VIDEO_VALUES.has(val)) { + video = val as FfmpegVideoOption; + } else { + extraFragments.push(frag); + } + } else if (frag.startsWith("audio=")) { + const val = frag.slice(6); + if (AUDIO_VALUES.has(val)) { + audio = val as FfmpegAudioOption; + } else { + extraFragments.push(frag); + } + } else if (frag === "hardware") { + hardware = "auto"; + } else if (frag.startsWith("hardware=")) { + const val = frag.slice(9); + if (HARDWARE_SPECIFIC.has(val)) { + hardware = "auto"; + } else { + extraFragments.push(frag); + } + } else { + extraFragments.push(frag); + } + } + + const hasAnyKnownFragment = video !== null || audio !== null; + + return { + isFfmpeg: true, + baseUrl, + video: video ?? (hasAnyKnownFragment ? "exclude" : "copy"), + audio: audio ?? (hasAnyKnownFragment ? "exclude" : "copy"), + hardware, + extraFragments, + }; +} + +export function buildFfmpegUrl(parsed: ParsedFfmpegUrl): string { + let url = `ffmpeg:${parsed.baseUrl}`; + + if (parsed.video !== "exclude") { + url += `#video=${parsed.video}`; + } + if (parsed.audio !== "exclude") { + url += `#audio=${parsed.audio}`; + } + if (parsed.hardware === "auto") { + url += "#hardware"; + } + for (const frag of parsed.extraFragments) { + url += `#${frag}`; + } + + return url; +} + +export function toggleFfmpegMode(url: string, enable: boolean): string { + if (enable) { + if (url.startsWith("ffmpeg:")) { + return url; + } + return `ffmpeg:${url}#video=copy#audio=copy`; + } + + if (!url.startsWith("ffmpeg:")) { + return url; + } + + const withoutPrefix = url.slice(7); + const baseUrl = withoutPrefix.split("#")[0]; + return baseUrl; +} diff --git a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx new file mode 100644 index 000000000..dc6f1533c --- /dev/null +++ b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx @@ -0,0 +1,1009 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR from "swr"; +import axios from "axios"; +import isEqual from "lodash/isEqual"; +import { toast } from "sonner"; +import { + LuChevronDown, + LuExternalLink, + LuEye, + LuEyeOff, + LuPencil, + LuPlus, + LuTrash2, +} from "react-icons/lu"; +import { Link } from "react-router-dom"; +import Heading from "@/components/ui/heading"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { useDocDomain } from "@/hooks/use-doc-domain"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { cn } from "@/lib/utils"; +import { + isMaskedPath, + hasCredentials, + maskCredentials, +} from "@/utils/credentialMask"; +import { + parseFfmpegUrl, + buildFfmpegUrl, + toggleFfmpegMode, + type FfmpegVideoOption, + type FfmpegAudioOption, + type FfmpegHardwareOption, +} from "@/utils/go2rtcFfmpeg"; + +type RawPathsResponse = { + cameras: Record< + string, + { ffmpeg: { inputs: { path: string; roles: string[] }[] } } + >; + go2rtc: { streams: Record }; +}; + +type Go2RtcStreamsSettingsViewProps = { + setUnsavedChanges: React.Dispatch>; + onSectionStatusChange?: ( + sectionKey: string, + level: "global" | "camera", + status: { + hasChanges: boolean; + isOverridden: boolean; + hasValidationErrors: boolean; + }, + ) => void; +}; + +const STREAM_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; + +function normalizeStreams( + streams: Record | undefined, +): Record { + if (!streams) return {}; + const result: Record = {}; + for (const [name, urls] of Object.entries(streams)) { + result[name] = Array.isArray(urls) ? urls : [urls]; + } + return result; +} + +export default function Go2RtcStreamsSettingsView({ + setUnsavedChanges, + onSectionStatusChange, +}: Go2RtcStreamsSettingsViewProps) { + const { t } = useTranslation(["views/settings", "common"]); + const { getLocaleDocUrl } = useDocDomain(); + const { data: config, mutate: updateConfig } = + useSWR("config"); + const { data: rawPaths, mutate: updateRawPaths } = + useSWR("config/raw_paths"); + + const [editedStreams, setEditedStreams] = useState>( + {}, + ); + const [serverStreams, setServerStreams] = useState>( + {}, + ); + const [initialized, setInitialized] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [credentialVisibility, setCredentialVisibility] = useState< + Record + >({}); + const [deleteDialog, setDeleteDialog] = useState(null); + const [renameDialog, setRenameDialog] = useState(null); + const [addStreamDialogOpen, setAddStreamDialogOpen] = useState(false); + const [newlyAdded, setNewlyAdded] = useState>(new Set()); + + // Initialize from config — wait for both config and rawPaths to avoid + // a mismatch when rawPaths arrives after config with different data + useEffect(() => { + if (!config || !rawPaths) return; + + // Always use rawPaths for go2rtc streams — the /config endpoint masks + // credentials, so using config.go2rtc.streams would save masked values + const normalized = normalizeStreams(rawPaths.go2rtc?.streams); + + setServerStreams(normalized); + if (!initialized) { + setEditedStreams(normalized); + setInitialized(true); + } + }, [config, rawPaths, initialized]); + + // Track unsaved changes + const hasChanges = useMemo( + () => initialized && !isEqual(editedStreams, serverStreams), + [editedStreams, serverStreams, initialized], + ); + + useEffect(() => { + setUnsavedChanges(hasChanges); + }, [hasChanges, setUnsavedChanges]); + + const hasValidationErrors = useMemo(() => { + const names = Object.keys(editedStreams); + const seenNames = new Set(); + + for (const name of names) { + if (!name.trim() || !STREAM_NAME_PATTERN.test(name)) return true; + if (seenNames.has(name)) return true; + seenNames.add(name); + + const urls = editedStreams[name]; + if (!urls || urls.length === 0 || urls.every((u) => !u.trim())) + return true; + } + + return false; + }, [editedStreams]); + + // Report status to parent for sidebar red dot + useEffect(() => { + onSectionStatusChange?.("go2rtc_streams", "global", { + hasChanges: hasChanges, + isOverridden: false, + hasValidationErrors, + }); + }, [hasChanges, hasValidationErrors, onSectionStatusChange]); + + // Save handler + const saveToConfig = useCallback(async () => { + setIsLoading(true); + + try { + const streamsPayload: Record = { + ...editedStreams, + }; + const deletedStreamNames = Object.keys(serverStreams).filter( + (name) => !(name in editedStreams), + ); + for (const deleted of deletedStreamNames) { + streamsPayload[deleted] = ""; + } + + await axios.put("config/set", { + requires_restart: 0, + config_data: { go2rtc: { streams: streamsPayload } }, + }); + + // Update running go2rtc instance + const go2rtcUpdates: Promise[] = []; + for (const [streamName, urls] of Object.entries(editedStreams)) { + if (urls[0]) { + go2rtcUpdates.push( + axios.put( + `go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`, + ), + ); + } + } + for (const deleted of deletedStreamNames) { + go2rtcUpdates.push(axios.delete(`go2rtc/streams/${deleted}`)); + } + await Promise.allSettled(go2rtcUpdates); + + toast.success( + t("toast.success", { + ns: "views/settings", + defaultValue: "Settings saved successfully", + }), + ); + + setServerStreams(editedStreams); + updateConfig(); + updateRawPaths(); + } catch { + toast.error( + t("toast.error", { + ns: "views/settings", + defaultValue: "Failed to save settings", + }), + ); + } finally { + setIsLoading(false); + } + }, [editedStreams, serverStreams, t, updateConfig, updateRawPaths]); + + // Reset handler + const onReset = useCallback(() => { + setEditedStreams(serverStreams); + setCredentialVisibility({}); + }, [serverStreams]); + + // Stream CRUD operations + const addStream = useCallback((name: string) => { + setEditedStreams((prev) => ({ ...prev, [name]: [""] })); + setNewlyAdded((prev) => new Set(prev).add(name)); + setAddStreamDialogOpen(false); + }, []); + + const deleteStream = useCallback((streamName: string) => { + setEditedStreams((prev) => { + const { [streamName]: _, ...rest } = prev; + return rest; + }); + setDeleteDialog(null); + }, []); + + const renameStream = useCallback((oldName: string, newName: string) => { + if (oldName === newName || !newName.trim()) return; + + setEditedStreams((prev) => { + const urls = prev[oldName]; + if (!urls) return prev; + + const entries = Object.entries(prev); + const result: Record = {}; + for (const [key, value] of entries) { + if (key === oldName) { + result[newName] = value; + } else { + result[key] = value; + } + } + return result; + }); + }, []); + + const updateUrl = useCallback( + (streamName: string, urlIndex: number, newUrl: string) => { + setEditedStreams((prev) => { + const urls = [...(prev[streamName] || [])]; + urls[urlIndex] = newUrl; + return { ...prev, [streamName]: urls }; + }); + }, + [], + ); + + const addUrl = useCallback((streamName: string) => { + setEditedStreams((prev) => { + const urls = [...(prev[streamName] || []), ""]; + return { ...prev, [streamName]: urls }; + }); + }, []); + + const removeUrl = useCallback((streamName: string, urlIndex: number) => { + setEditedStreams((prev) => { + const urls = (prev[streamName] || []).filter((_, i) => i !== urlIndex); + return { ...prev, [streamName]: urls.length > 0 ? urls : [""] }; + }); + }, []); + + const toggleCredentialVisibility = useCallback((key: string) => { + setCredentialVisibility((prev) => ({ ...prev, [key]: !prev[key] })); + }, []); + + if (!config) return null; + + const streamEntries = Object.entries(editedStreams); + + return ( +
+
+
+ {t("go2rtcStreams.title")} +
+ {t("go2rtcStreams.description")} +
+
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+
+ + {streamEntries.length === 0 && ( +
+ {t("go2rtcStreams.noStreams")} +
+ )} + +
+ {streamEntries.map(([streamName, urls]) => ( + setRenameDialog(streamName)} + onDelete={() => setDeleteDialog(streamName)} + onUpdateUrl={updateUrl} + onAddUrl={() => addUrl(streamName)} + onRemoveUrl={(urlIndex) => removeUrl(streamName, urlIndex)} + onToggleCredentialVisibility={toggleCredentialVisibility} + /> + ))} +
+ + +
+ + {/* Sticky save/undo buttons */} +
+
+ {hasChanges && ( +
+ {t("unsavedChanges")} +
+ )} +
+ {hasChanges && ( + + )} + +
+
+
+ + {/* Delete confirmation dialog */} + { + if (!open) setDeleteDialog(null); + }} + > + + + + {t("go2rtcStreams.deleteStream")} + + + {t("go2rtcStreams.deleteStreamConfirm", { + streamName: deleteDialog ?? "", + })} + + + + + {t("button.cancel", { ns: "common" })} + + deleteDialog && deleteStream(deleteDialog)} + > + {t("go2rtcStreams.deleteStream")} + + + + + + {/* Rename dialog */} + { + renameStream(oldName, newName); + setRenameDialog(null); + }} + onClose={() => setRenameDialog(null)} + /> + + setAddStreamDialogOpen(false)} + /> +
+ ); +} + +// --- RenameStreamDialog --- + +type RenameStreamDialogProps = { + open: boolean; + streamName: string; + allStreamNames: string[]; + onRename: (oldName: string, newName: string) => void; + onClose: () => void; +}; + +function RenameStreamDialog({ + open, + streamName, + allStreamNames, + onRename, + onClose, +}: RenameStreamDialogProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [newName, setNewName] = useState(""); + + useEffect(() => { + if (open) { + setNewName(streamName); + } + }, [open, streamName]); + + const nameError = useMemo(() => { + if (!newName.trim()) { + return t("go2rtcStreams.validation.nameRequired"); + } + if (!STREAM_NAME_PATTERN.test(newName)) { + return t("go2rtcStreams.validation.nameInvalid"); + } + if (newName !== streamName && allStreamNames.includes(newName)) { + return t("go2rtcStreams.validation.nameDuplicate"); + } + return null; + }, [newName, streamName, allStreamNames, t]); + + const canSubmit = !nameError && newName !== streamName; + + return ( + !v && onClose()}> + + + {t("go2rtcStreams.renameStream")} + + {t("go2rtcStreams.renameStreamDesc")} + + +
+ + setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && canSubmit) { + onRename(streamName, newName); + } + }} + autoFocus + /> + {nameError && newName !== streamName && ( +

{nameError}

+ )} +
+ + + + + + +
+
+ ); +} + +type AddStreamDialogProps = { + open: boolean; + allStreamNames: string[]; + onAdd: (name: string) => void; + onClose: () => void; +}; + +function AddStreamDialog({ + open, + allStreamNames, + onAdd, + onClose, +}: AddStreamDialogProps) { + const { t } = useTranslation(["views/settings", "common"]); + const [name, setName] = useState(""); + + useEffect(() => { + if (open) { + setName(""); + } + }, [open]); + + const nameError = useMemo(() => { + if (!name.trim()) { + return t("go2rtcStreams.validation.nameRequired"); + } + if (!STREAM_NAME_PATTERN.test(name)) { + return t("go2rtcStreams.validation.nameInvalid"); + } + if (allStreamNames.includes(name)) { + return t("go2rtcStreams.validation.nameDuplicate"); + } + return null; + }, [name, allStreamNames, t]); + + const canSubmit = !nameError; + + return ( + !v && onClose()}> + + + {t("go2rtcStreams.addStream")} + + {t("go2rtcStreams.addStreamDesc")} + + +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && canSubmit) { + onAdd(name); + } + }} + placeholder="camera_name" + autoFocus + /> + {nameError && name.length > 0 && ( +

{nameError}

+ )} +
+ + + + + + +
+
+ ); +} + +type StreamCardProps = { + streamName: string; + urls: string[]; + credentialVisibility: Record; + onRename: () => void; + onDelete: () => void; + onUpdateUrl: (streamName: string, urlIndex: number, newUrl: string) => void; + onAddUrl: () => void; + onRemoveUrl: (urlIndex: number) => void; + onToggleCredentialVisibility: (key: string) => void; + defaultOpen?: boolean; +}; + +function StreamCard({ + streamName, + urls, + credentialVisibility, + onRename, + onDelete, + onUpdateUrl, + onAddUrl, + onRemoveUrl, + onToggleCredentialVisibility, + defaultOpen = false, +}: StreamCardProps) { + const { t } = useTranslation("views/settings"); + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( + + + +
+
+

{streamName}

+ +
+
+ + + + +
+
+ +
+ {urls.map((url, urlIndex) => ( + 1} + showCredentials={ + credentialVisibility[`${streamName}-${urlIndex}`] ?? false + } + onUpdateUrl={onUpdateUrl} + onRemoveUrl={() => onRemoveUrl(urlIndex)} + onToggleCredentialVisibility={() => + onToggleCredentialVisibility(`${streamName}-${urlIndex}`) + } + /> + ))} + +
+
+
+
+
+ ); +} + +type StreamUrlEntryProps = { + streamName: string; + url: string; + urlIndex: number; + canRemove: boolean; + showCredentials: boolean; + onUpdateUrl: (streamName: string, urlIndex: number, newUrl: string) => void; + onRemoveUrl: () => void; + onToggleCredentialVisibility: () => void; +}; + +function StreamUrlEntry({ + streamName, + url, + urlIndex, + canRemove, + showCredentials, + onUpdateUrl, + onRemoveUrl, + onToggleCredentialVisibility, +}: StreamUrlEntryProps) { + const { t } = useTranslation("views/settings"); + const [isFocused, setIsFocused] = useState(false); + const parsed = useMemo(() => parseFfmpegUrl(url), [url]); + + const rawBaseUrl = parsed.isFfmpeg ? parsed.baseUrl : url; + const canToggleCredentials = + hasCredentials(rawBaseUrl) && !isMaskedPath(rawBaseUrl); + + const baseUrlForDisplay = useMemo(() => { + // Never mask while the input is focused — the user may be typing credentials + if (isFocused) return rawBaseUrl; + if (!showCredentials && hasCredentials(rawBaseUrl)) { + return maskCredentials(rawBaseUrl); + } + return rawBaseUrl; + }, [rawBaseUrl, showCredentials, isFocused]); + + const isTranscodingVideo = + parsed.isFfmpeg && parsed.video !== "copy" && parsed.video !== "exclude"; + + const handleBaseUrlChange = useCallback( + (newBaseUrl: string) => { + if (parsed.isFfmpeg) { + const newUrl = buildFfmpegUrl({ ...parsed, baseUrl: newBaseUrl }); + onUpdateUrl(streamName, urlIndex, newUrl); + } else { + onUpdateUrl(streamName, urlIndex, newBaseUrl); + } + }, + [parsed, streamName, urlIndex, onUpdateUrl], + ); + + const handleFfmpegToggle = useCallback( + (enabled: boolean) => { + const newUrl = toggleFfmpegMode(url, enabled); + onUpdateUrl(streamName, urlIndex, newUrl); + }, + [url, streamName, urlIndex, onUpdateUrl], + ); + + const handleFfmpegOptionChange = useCallback( + ( + field: "video" | "audio" | "hardware", + value: FfmpegVideoOption | FfmpegAudioOption | FfmpegHardwareOption, + ) => { + const updated = { ...parsed, [field]: value }; + // Clear hardware when switching away from transcoding video + if (field === "video" && (value === "copy" || value === "exclude")) { + updated.hardware = "none"; + } + const newUrl = buildFfmpegUrl(updated); + onUpdateUrl(streamName, urlIndex, newUrl); + }, + [parsed, streamName, urlIndex, onUpdateUrl], + ); + + const audioDisplayLabel = useMemo(() => { + const labels: Record = { + copy: t("go2rtcStreams.ffmpeg.audioCopy"), + aac: t("go2rtcStreams.ffmpeg.audioAac"), + opus: t("go2rtcStreams.ffmpeg.audioOpus"), + pcmu: t("go2rtcStreams.ffmpeg.audioPcmu"), + pcma: t("go2rtcStreams.ffmpeg.audioPcma"), + pcm: t("go2rtcStreams.ffmpeg.audioPcm"), + mp3: t("go2rtcStreams.ffmpeg.audioMp3"), + exclude: t("go2rtcStreams.ffmpeg.audioExclude"), + }; + return labels[parsed.audio] || parsed.audio; + }, [parsed.audio, t]); + + return ( +
+
+
+ handleBaseUrlChange(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + placeholder={t("go2rtcStreams.streamUrlPlaceholder")} + /> + {canToggleCredentials && ( + + )} +
+ {canRemove && ( + + )} +
+ + {/* ffmpeg module toggle */} +
+ + +
+ + {/* ffmpeg options */} + {parsed.isFfmpeg && ( +
+ {/* Video */} +
+ + +
+ + {/* Audio */} +
+ + +
+ + {/* Hardware acceleration - only when transcoding video */} + {isTranscodingVideo && ( +
+ + +
+ )} +
+ )} +
+ ); +}