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 && (
)}
)}
); }