mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-08 14:35:26 +03:00
* use ffmpeg to probe rtsp urls instead of cv2 cv2 is faster (no subprocess launch) and will continue to be used for recording segments * tweak faq * change unsaved color to orange avoids confusion with validation errors (red) * don't use any variant of orange as a profile color avoids confusion with unsaved changes * more unsaved color tweaks
1012 lines
31 KiB
TypeScript
1012 lines
31 KiB
TypeScript
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<string, string | string[]> };
|
|
};
|
|
|
|
type Go2RtcStreamsSettingsViewProps = {
|
|
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
|
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<string, string | string[]> | undefined,
|
|
): Record<string, string[]> {
|
|
if (!streams) return {};
|
|
const result: Record<string, string[]> = {};
|
|
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<FrigateConfig>("config");
|
|
const { data: rawPaths, mutate: updateRawPaths } =
|
|
useSWR<RawPathsResponse>("config/raw_paths");
|
|
|
|
const [editedStreams, setEditedStreams] = useState<Record<string, string[]>>(
|
|
{},
|
|
);
|
|
const [serverStreams, setServerStreams] = useState<Record<string, string[]>>(
|
|
{},
|
|
);
|
|
const [initialized, setInitialized] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [credentialVisibility, setCredentialVisibility] = useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
const [deleteDialog, setDeleteDialog] = useState<string | null>(null);
|
|
const [renameDialog, setRenameDialog] = useState<string | null>(null);
|
|
const [addStreamDialogOpen, setAddStreamDialogOpen] = useState(false);
|
|
const [newlyAdded, setNewlyAdded] = useState<Set<string>>(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<string>();
|
|
|
|
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<string, string[] | string> = {
|
|
...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<unknown>[] = [];
|
|
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<string, string[]> = {};
|
|
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 (
|
|
<div className="flex size-full flex-col lg:pr-2">
|
|
<div className="max-w-4xl">
|
|
<div className="mb-5 flex flex-col">
|
|
<Heading as="h4">{t("go2rtcStreams.title")}</Heading>
|
|
<div className="my-1 text-sm text-muted-foreground">
|
|
{t("go2rtcStreams.description")}
|
|
</div>
|
|
<div className="flex items-center text-sm text-primary-variant">
|
|
<Link
|
|
to={getLocaleDocUrl("guides/configuring_go2rtc")}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline"
|
|
>
|
|
{t("readTheDocumentation", { ns: "common" })}
|
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{streamEntries.length === 0 && (
|
|
<div className="my-8 text-center text-sm text-muted-foreground">
|
|
{t("go2rtcStreams.noStreams")}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
{streamEntries.map(([streamName, urls]) => (
|
|
<StreamCard
|
|
key={streamName}
|
|
streamName={streamName}
|
|
urls={urls}
|
|
credentialVisibility={credentialVisibility}
|
|
defaultOpen={newlyAdded.has(streamName)}
|
|
onRename={() => setRenameDialog(streamName)}
|
|
onDelete={() => setDeleteDialog(streamName)}
|
|
onUpdateUrl={updateUrl}
|
|
onAddUrl={() => addUrl(streamName)}
|
|
onRemoveUrl={(urlIndex) => removeUrl(streamName, urlIndex)}
|
|
onToggleCredentialVisibility={toggleCredentialVisibility}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={() => setAddStreamDialogOpen(true)}
|
|
variant="outline"
|
|
className="my-4"
|
|
>
|
|
<LuPlus className="mr-2 size-4" />
|
|
{t("go2rtcStreams.addStream")}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Sticky save/undo buttons */}
|
|
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pt-0">
|
|
<div
|
|
className={cn(
|
|
"flex flex-col items-center gap-4 pt-2 md:flex-row",
|
|
hasChanges ? "justify-between" : "justify-end",
|
|
)}
|
|
>
|
|
{hasChanges && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-unsaved">
|
|
{t("unsavedChanges")}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex w-full items-center gap-2 md:w-auto">
|
|
{hasChanges && (
|
|
<Button
|
|
onClick={onReset}
|
|
variant="outline"
|
|
disabled={isLoading}
|
|
className="flex min-w-36 flex-1 gap-2"
|
|
>
|
|
{t("button.undo", { ns: "common" })}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={saveToConfig}
|
|
variant="select"
|
|
disabled={!hasChanges || isLoading || hasValidationErrors}
|
|
className="flex min-w-36 flex-1 gap-2"
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<ActivityIndicator className="h-4 w-4" />
|
|
{t("button.saving", { ns: "common" })}
|
|
</>
|
|
) : (
|
|
t("button.save", { ns: "common" })
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Delete confirmation dialog */}
|
|
<AlertDialog
|
|
open={deleteDialog !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDeleteDialog(null);
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("go2rtcStreams.deleteStream")}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("go2rtcStreams.deleteStreamConfirm", {
|
|
streamName: deleteDialog ?? "",
|
|
})}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className={cn(
|
|
buttonVariants({ variant: "destructive" }),
|
|
"text-white",
|
|
)}
|
|
onClick={() => deleteDialog && deleteStream(deleteDialog)}
|
|
>
|
|
{t("go2rtcStreams.deleteStream")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Rename dialog */}
|
|
<RenameStreamDialog
|
|
open={renameDialog !== null}
|
|
streamName={renameDialog ?? ""}
|
|
allStreamNames={Object.keys(editedStreams)}
|
|
onRename={(oldName, newName) => {
|
|
renameStream(oldName, newName);
|
|
setRenameDialog(null);
|
|
}}
|
|
onClose={() => setRenameDialog(null)}
|
|
/>
|
|
|
|
<AddStreamDialog
|
|
open={addStreamDialogOpen}
|
|
allStreamNames={Object.keys(editedStreams)}
|
|
onAdd={addStream}
|
|
onClose={() => setAddStreamDialogOpen(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- 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 (
|
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("go2rtcStreams.renameStream")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("go2rtcStreams.renameStreamDesc")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2 py-2">
|
|
<Label>{t("go2rtcStreams.newStreamName")}</Label>
|
|
<Input
|
|
className="text-md"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && canSubmit) {
|
|
onRename(streamName, newName);
|
|
}
|
|
}}
|
|
autoFocus
|
|
/>
|
|
{nameError && newName !== streamName && (
|
|
<p className="text-xs text-destructive">{nameError}</p>
|
|
)}
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:justify-end md:gap-0">
|
|
<DialogClose asChild>
|
|
<Button>{t("button.cancel", { ns: "common" })}</Button>
|
|
</DialogClose>
|
|
<Button
|
|
variant="select"
|
|
disabled={!canSubmit}
|
|
onClick={() => onRename(streamName, newName)}
|
|
>
|
|
{t("go2rtcStreams.renameStream")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("go2rtcStreams.addStream")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("go2rtcStreams.addStreamDesc")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2 py-2">
|
|
<Label>{t("go2rtcStreams.streamName")}</Label>
|
|
<Input
|
|
value={name}
|
|
className="text-md"
|
|
onChange={(e) => setName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && canSubmit) {
|
|
onAdd(name);
|
|
}
|
|
}}
|
|
placeholder="camera_name"
|
|
autoFocus
|
|
/>
|
|
{nameError && name.length > 0 && (
|
|
<p className="text-xs text-destructive">{nameError}</p>
|
|
)}
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:justify-end md:gap-0">
|
|
<DialogClose asChild>
|
|
<Button>{t("button.cancel", { ns: "common" })}</Button>
|
|
</DialogClose>
|
|
<Button
|
|
variant="select"
|
|
disabled={!canSubmit}
|
|
onClick={() => onAdd(name)}
|
|
>
|
|
{t("go2rtcStreams.addStream")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
type StreamCardProps = {
|
|
streamName: string;
|
|
urls: string[];
|
|
credentialVisibility: Record<string, boolean>;
|
|
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 (
|
|
<Card className="bg-secondary text-primary">
|
|
<CardContent className="p-0">
|
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
<div className="flex items-center justify-between px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="font-medium">{streamName}</h4>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onRename}
|
|
className="size-7 p-0 text-muted-foreground hover:text-primary"
|
|
>
|
|
<LuPencil className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onDelete}
|
|
className="text-secondary-foreground hover:text-secondary-foreground"
|
|
>
|
|
<LuTrash2 className="size-5" />
|
|
</Button>
|
|
<CollapsibleTrigger asChild>
|
|
<Button variant="ghost" size="sm">
|
|
<LuChevronDown
|
|
className={cn(
|
|
"size-5 transition-transform",
|
|
isOpen && "rotate-180",
|
|
)}
|
|
/>
|
|
</Button>
|
|
</CollapsibleTrigger>
|
|
</div>
|
|
</div>
|
|
<CollapsibleContent>
|
|
<div className="space-y-3 px-4 pb-4">
|
|
{urls.map((url, urlIndex) => (
|
|
<StreamUrlEntry
|
|
key={urlIndex}
|
|
streamName={streamName}
|
|
url={url}
|
|
urlIndex={urlIndex}
|
|
canRemove={urls.length > 1}
|
|
showCredentials={
|
|
credentialVisibility[`${streamName}-${urlIndex}`] ?? false
|
|
}
|
|
onUpdateUrl={onUpdateUrl}
|
|
onRemoveUrl={() => onRemoveUrl(urlIndex)}
|
|
onToggleCredentialVisibility={() =>
|
|
onToggleCredentialVisibility(`${streamName}-${urlIndex}`)
|
|
}
|
|
/>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onAddUrl}
|
|
className="w-fit"
|
|
>
|
|
<LuPlus className="mr-2 size-4" />
|
|
{t("go2rtcStreams.addUrl")}
|
|
</Button>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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<string, string> = {
|
|
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 (
|
|
<div className="space-y-2 rounded-lg bg-background p-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative flex-1">
|
|
<Input
|
|
className="text-md h-8 pr-10"
|
|
value={baseUrlForDisplay}
|
|
onChange={(e) => handleBaseUrlChange(e.target.value)}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => setIsFocused(false)}
|
|
placeholder={t("go2rtcStreams.streamUrlPlaceholder")}
|
|
/>
|
|
{canToggleCredentials && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
|
onClick={onToggleCredentialVisibility}
|
|
>
|
|
{showCredentials ? (
|
|
<LuEyeOff className="size-4" />
|
|
) : (
|
|
<LuEye className="size-4" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{canRemove && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onRemoveUrl}
|
|
className="text-secondary-foreground hover:text-secondary-foreground"
|
|
>
|
|
<LuTrash2 className="size-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* ffmpeg module toggle */}
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
checked={parsed.isFfmpeg}
|
|
onCheckedChange={handleFfmpegToggle}
|
|
/>
|
|
<Label className="text-sm">
|
|
{t("go2rtcStreams.ffmpeg.useFfmpegModule")}
|
|
</Label>
|
|
</div>
|
|
|
|
{/* ffmpeg options */}
|
|
{parsed.isFfmpeg && (
|
|
<div
|
|
className={cn(
|
|
"grid grid-cols-1 gap-3 pl-4",
|
|
isTranscodingVideo ? "sm:grid-cols-3" : "sm:grid-cols-2",
|
|
)}
|
|
>
|
|
{/* Video */}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">
|
|
{t("go2rtcStreams.ffmpeg.video")}
|
|
</Label>
|
|
<Select
|
|
value={parsed.video}
|
|
onValueChange={(v) =>
|
|
handleFfmpegOptionChange("video", v as FfmpegVideoOption)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8">
|
|
{parsed.video === "copy"
|
|
? t("go2rtcStreams.ffmpeg.videoCopy")
|
|
: parsed.video === "h264"
|
|
? t("go2rtcStreams.ffmpeg.videoH264")
|
|
: parsed.video === "h265"
|
|
? t("go2rtcStreams.ffmpeg.videoH265")
|
|
: t("go2rtcStreams.ffmpeg.videoExclude")}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="copy">
|
|
{t("go2rtcStreams.ffmpeg.videoCopy")}
|
|
</SelectItem>
|
|
<SelectItem value="h264">
|
|
{t("go2rtcStreams.ffmpeg.videoH264")}
|
|
</SelectItem>
|
|
<SelectItem value="h265">
|
|
{t("go2rtcStreams.ffmpeg.videoH265")}
|
|
</SelectItem>
|
|
<SelectItem value="exclude">
|
|
{t("go2rtcStreams.ffmpeg.videoExclude")}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Audio */}
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">
|
|
{t("go2rtcStreams.ffmpeg.audio")}
|
|
</Label>
|
|
<Select
|
|
value={parsed.audio}
|
|
onValueChange={(v) =>
|
|
handleFfmpegOptionChange("audio", v as FfmpegAudioOption)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8">{audioDisplayLabel}</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="copy">
|
|
{t("go2rtcStreams.ffmpeg.audioCopy")}
|
|
</SelectItem>
|
|
<SelectItem value="aac">
|
|
{t("go2rtcStreams.ffmpeg.audioAac")}
|
|
</SelectItem>
|
|
<SelectItem value="opus">
|
|
{t("go2rtcStreams.ffmpeg.audioOpus")}
|
|
</SelectItem>
|
|
<SelectItem value="pcmu">
|
|
{t("go2rtcStreams.ffmpeg.audioPcmu")}
|
|
</SelectItem>
|
|
<SelectItem value="pcma">
|
|
{t("go2rtcStreams.ffmpeg.audioPcma")}
|
|
</SelectItem>
|
|
<SelectItem value="pcm">
|
|
{t("go2rtcStreams.ffmpeg.audioPcm")}
|
|
</SelectItem>
|
|
<SelectItem value="mp3">
|
|
{t("go2rtcStreams.ffmpeg.audioMp3")}
|
|
</SelectItem>
|
|
<SelectItem value="exclude">
|
|
{t("go2rtcStreams.ffmpeg.audioExclude")}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Hardware acceleration - only when transcoding video */}
|
|
{isTranscodingVideo && (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs font-medium">
|
|
{t("go2rtcStreams.ffmpeg.hardware")}
|
|
</Label>
|
|
<Select
|
|
value={parsed.hardware}
|
|
onValueChange={(v) =>
|
|
handleFfmpegOptionChange(
|
|
"hardware",
|
|
v as FfmpegHardwareOption,
|
|
)
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8">
|
|
{parsed.hardware === "auto"
|
|
? t("go2rtcStreams.ffmpeg.hardwareAuto")
|
|
: t("go2rtcStreams.ffmpeg.hardwareNone")}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem value="none">
|
|
{t("go2rtcStreams.ffmpeg.hardwareNone")}
|
|
</SelectItem>
|
|
<SelectItem value="auto">
|
|
{t("go2rtcStreams.ffmpeg.hardwareAuto")}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|