From 4fdc107987f57e1abf24445a15834d3b8f0de063 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 19 May 2026 09:30:04 -0500 Subject: [PATCH] Improve go2rtc pane in Settings (#23251) * improve layout and handling of multiple ffmpeg args in go2rtc pane * add e2e tests * fix spacing --- web/e2e/specs/settings/go2rtc-streams.spec.ts | 235 ++++++++ web/public/locales/en/views/settings.json | 11 +- web/src/utils/go2rtcFfmpeg.ts | 111 ++-- .../settings/Go2RtcStreamsSettingsView.tsx | 521 +++++++++++------- 4 files changed, 641 insertions(+), 237 deletions(-) create mode 100644 web/e2e/specs/settings/go2rtc-streams.spec.ts diff --git a/web/e2e/specs/settings/go2rtc-streams.spec.ts b/web/e2e/specs/settings/go2rtc-streams.spec.ts new file mode 100644 index 0000000000..223a261bef --- /dev/null +++ b/web/e2e/specs/settings/go2rtc-streams.spec.ts @@ -0,0 +1,235 @@ +/** + * go2rtc streams settings page tests -- MEDIUM tier. + * + * Regression coverage for the compat-mode (ffmpeg:) URL editor: unknown + * fragments like #timeout=10 must remain visible and editable when the + * stream is using compatibility mode. + */ + +import { test, expect } from "../../fixtures/frigate-test"; +import type { Page } from "@playwright/test"; + +const STREAM_NAME = "dome_sub"; +const FFMPEG_URL_WITH_TIMEOUT = + "ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10"; + +async function installRawPathsRoute(page: Page, streamUrl: string) { + let lastSavedConfig: unknown = null; + await page.route("**/api/config/raw_paths", (route) => + route.fulfill({ + json: { + cameras: {}, + go2rtc: { streams: { [STREAM_NAME]: [streamUrl] } }, + }, + }), + ); + await page.route("**/api/config/set", async (route) => { + lastSavedConfig = route.request().postDataJSON(); + await route.fulfill({ json: { success: true, require_restart: false } }); + }); + return { + capturedConfig: () => lastSavedConfig, + }; +} + +async function expandStream(page: Page, streamName: string) { + // Each StreamCard renders the stream name as an h4 next to a rename + // button, with the chevron toggle as the last button in the header row. + // Scope to the header row (h4's grandparent) and click that last button. + const headerRow = page + .locator(`h4:text-is("${streamName}")`) + .locator("xpath=../.."); + await headerRow.getByRole("button").last().click(); +} + +test.describe("go2rtc streams settings — ffmpeg compat mode @medium", () => { + test("preserves unknown fragments like #timeout= in the URL input", async ({ + frigateApp, + }) => { + await installRawPathsRoute(frigateApp.page, FFMPEG_URL_WITH_TIMEOUT); + await frigateApp.goto("/settings?page=systemGo2rtcStreams"); + + await expect( + frigateApp.page.getByRole("heading", { name: STREAM_NAME }), + ).toBeVisible(); + + await expandStream(frigateApp.page, STREAM_NAME); + + const urlInput = frigateApp.page.getByPlaceholder( + "e.g., rtsp://user:pass@192.168.1.100/stream", + ); + await expect(urlInput).toBeVisible(); + + // Focus the input so credential masking is bypassed and the raw value + // is rendered — this matches how a user would inspect the URL before + // editing it. + await urlInput.focus(); + await expect(urlInput).toHaveValue( + "rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10", + ); + }); + + test("lets the user add an extra fragment in compat mode", async ({ + frigateApp, + }) => { + const capture = await installRawPathsRoute( + frigateApp.page, + FFMPEG_URL_WITH_TIMEOUT, + ); + await frigateApp.goto("/settings?page=systemGo2rtcStreams"); + await expandStream(frigateApp.page, STREAM_NAME); + + const urlInput = frigateApp.page.getByPlaceholder( + "e.g., rtsp://user:pass@192.168.1.100/stream", + ); + await urlInput.focus(); + await urlInput.fill( + "rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0", + ); + await urlInput.blur(); + + // Reopen and re-focus to assert the new value round-tripped through + // parseFfmpegBaseAndExtras + buildFfmpegUrl back into the displayed text. + await urlInput.focus(); + await expect(urlInput).toHaveValue( + "rtsp://user:pass@192.168.0.20:554/Stream1#timeout=10#backchannel=0", + ); + + // Save and verify the persisted URL includes both extras after the + // recognized video/audio directives. + await frigateApp.page.getByRole("button", { name: "Save" }).click(); + await expect + .poll(() => capture.capturedConfig(), { timeout: 5_000 }) + .toMatchObject({ + config_data: { + go2rtc: { + streams: { + [STREAM_NAME]: [ + "ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#timeout=10#backchannel=0", + ], + }, + }, + }, + }); + }); + + test("preserves repeatable #audio= fallback chain and lets the user add another codec", async ({ + frigateApp, + }) => { + const capture = await installRawPathsRoute( + frigateApp.page, + // Idiomatic go2rtc fallback: copy if source has the codec, else transcode + "ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus", + ); + await frigateApp.goto("/settings?page=systemGo2rtcStreams"); + await expandStream(frigateApp.page, STREAM_NAME); + + // Two pre-populated audio rows — one per #audio= fragment. + const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`); + const audioRowsContainer = audioLabel.locator("xpath=../.."); + await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(2); + await expect(audioRowsContainer.getByRole("combobox").first()).toHaveText( + "Copy", + ); + await expect(audioRowsContainer.getByRole("combobox").nth(1)).toHaveText( + "Transcode to Opus", + ); + + // Add a third audio codec via the LuPlus next to the "Audio" label. + await audioRowsContainer + .getByRole("button", { name: "Add audio codec" }) + .click(); + await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(3); + + // Change the newly-added entry to AAC. + await audioRowsContainer.getByRole("combobox").nth(2).click(); + await frigateApp.page + .getByRole("option", { name: "Transcode to AAC" }) + .click(); + + await frigateApp.page.getByRole("button", { name: "Save" }).click(); + await expect + .poll(() => capture.capturedConfig(), { timeout: 5_000 }) + .toMatchObject({ + config_data: { + go2rtc: { + streams: { + [STREAM_NAME]: [ + "ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus#audio=aac", + ], + }, + }, + }, + }); + }); + + test("LuX is only shown on fallback rows and removes only that codec", async ({ + frigateApp, + }) => { + const capture = await installRawPathsRoute( + frigateApp.page, + "ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy#audio=opus", + ); + await frigateApp.goto("/settings?page=systemGo2rtcStreams"); + await expandStream(frigateApp.page, STREAM_NAME); + + const audioLabel = frigateApp.page.locator(`label:text-is("Audio")`); + const audioRowsContainer = audioLabel.locator("xpath=../.."); + const removeButtons = audioRowsContainer.getByRole("button", { + name: "Remove codec", + }); + // Primary (audio=copy) row is permanent and has no X; only the audio=opus + // fallback exposes a remove button. + await expect(removeButtons).toHaveCount(1); + + await removeButtons.first().click(); + await expect(audioRowsContainer.getByRole("combobox")).toHaveCount(1); + await expect(audioRowsContainer.getByRole("combobox")).toHaveText("Copy"); + + await frigateApp.page.getByRole("button", { name: "Save" }).click(); + await expect + .poll(() => capture.capturedConfig(), { timeout: 5_000 }) + .toMatchObject({ + config_data: { + go2rtc: { + streams: { + [STREAM_NAME]: [ + "ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy", + ], + }, + }, + }, + }); + }); + + test("picking Exclude on the primary row drops the #video= fragment entirely", async ({ + frigateApp, + }) => { + const capture = await installRawPathsRoute( + frigateApp.page, + "ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#video=copy#audio=copy", + ); + await frigateApp.goto("/settings?page=systemGo2rtcStreams"); + await expandStream(frigateApp.page, STREAM_NAME); + + const videoLabel = frigateApp.page.locator(`label:text-is("Video")`); + const videoRowsContainer = videoLabel.locator("xpath=../.."); + await videoRowsContainer.getByRole("combobox").first().click(); + await frigateApp.page.getByRole("option", { name: "Exclude" }).click(); + + await frigateApp.page.getByRole("button", { name: "Save" }).click(); + await expect + .poll(() => capture.capturedConfig(), { timeout: 5_000 }) + .toMatchObject({ + config_data: { + go2rtc: { + streams: { + [STREAM_NAME]: [ + "ffmpeg:rtsp://user:pass@192.168.0.20:554/Stream1#audio=copy", + ], + }, + }, + }, + }); + }); +}); diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 477f6e0f90..9f842b79c0 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1649,6 +1649,7 @@ "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", + "streamNumber": "Stream {{index}}", "streamName": "Stream name", "streamNamePlaceholder": "e.g., front_door", "streamUrlPlaceholder": "e.g., rtsp://user:pass@192.168.1.100/stream", @@ -1682,7 +1683,15 @@ "audioMp3": "Transcode to MP3", "audioExclude": "Exclude", "hardwareNone": "No hardware acceleration", - "hardwareAuto": "Automatic hardware acceleration" + "hardwareAuto": "Automatic (recommended)", + "hardwareVaapi": "VAAPI", + "hardwareCuda": "CUDA", + "hardwareV4l2m2m": "V4L2 M2M", + "hardwareDxva2": "DXVA2", + "hardwareVideotoolbox": "VideoToolbox", + "addVideoCodec": "Add video codec", + "addAudioCodec": "Add audio codec", + "removeCodec": "Remove codec" } }, "birdseye": { diff --git a/web/src/utils/go2rtcFfmpeg.ts b/web/src/utils/go2rtcFfmpeg.ts index 76f156993a..e9482f5251 100644 --- a/web/src/utils/go2rtcFfmpeg.ts +++ b/web/src/utils/go2rtcFfmpeg.ts @@ -8,13 +8,23 @@ export type FfmpegAudioOption = | "pcm" | "mp3" | "exclude"; -export type FfmpegHardwareOption = "none" | "auto"; +export type FfmpegHardwareOption = + | "none" + | "auto" + | "vaapi" + | "cuda" + | "v4l2m2m" + | "dxva2" + | "videotoolbox"; export type ParsedFfmpegUrl = { isFfmpeg: boolean; baseUrl: string; - video: FfmpegVideoOption; - audio: FfmpegAudioOption; + // go2rtc accepts repeatable #video=/#audio= fragments to express a fallback + // chain (copy if source codec matches, otherwise transcode). An empty array + // means no fragment is emitted for that track — equivalent to "exclude". + videos: FfmpegVideoOption[]; + audios: FfmpegAudioOption[]; hardware: FfmpegHardwareOption; extraFragments: string[]; }; @@ -37,13 +47,21 @@ const HARDWARE_SPECIFIC = new Set([ "videotoolbox", ]); +function isRecognizedFragment(frag: string): boolean { + if (frag === "hardware") return true; + if (frag.startsWith("video=")) return VIDEO_VALUES.has(frag.slice(6)); + if (frag.startsWith("audio=")) return AUDIO_VALUES.has(frag.slice(6)); + if (frag.startsWith("hardware=")) return HARDWARE_SPECIFIC.has(frag.slice(9)); + return false; +} + export function parseFfmpegUrl(url: string): ParsedFfmpegUrl { if (!url.startsWith("ffmpeg:")) { return { isFfmpeg: false, baseUrl: url, - video: "copy", - audio: "copy", + videos: [], + audios: [], hardware: "none", extraFragments: [], }; @@ -54,63 +72,76 @@ export function parseFfmpegUrl(url: string): ParsedFfmpegUrl { const baseUrl = parts[0]; const fragments = parts.slice(1); - let video: FfmpegVideoOption | null = null; - let audio: FfmpegAudioOption | null = null; + const videos: FfmpegVideoOption[] = []; + const audios: FfmpegAudioOption[] = []; 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); - } + if (frag.startsWith("video=") && VIDEO_VALUES.has(frag.slice(6))) { + videos.push(frag.slice(6) as FfmpegVideoOption); + } else if (frag.startsWith("audio=") && AUDIO_VALUES.has(frag.slice(6))) { + audios.push(frag.slice(6) as FfmpegAudioOption); } 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 if ( + frag.startsWith("hardware=") && + HARDWARE_SPECIFIC.has(frag.slice(9)) + ) { + hardware = frag.slice(9) as FfmpegHardwareOption; } else { extraFragments.push(frag); } } - const hasAnyKnownFragment = video !== null || audio !== null; - return { isFfmpeg: true, baseUrl, - video: video ?? (hasAnyKnownFragment ? "exclude" : "copy"), - audio: audio ?? (hasAnyKnownFragment ? "exclude" : "copy"), + // Guarantee at least one row per track so the UI always has a primary + // dropdown to render; "exclude" is the sentinel meaning "no fragment". + videos: videos.length > 0 ? videos : ["exclude"], + audios: audios.length > 0 ? audios : ["exclude"], hardware, extraFragments, }; } +// Splits the editable "base URL + extra fragments" portion of a compat-mode +// URL into its parts. Recognized fragments (video=, audio=, hardware) are +// dropped — they are managed by the dedicated controls in the UI. +export function parseFfmpegBaseAndExtras(input: string): { + baseUrl: string; + extraFragments: string[]; +} { + const cleaned = input.startsWith("ffmpeg:") ? input.slice(7) : input; + const parts = cleaned.split("#"); + const baseUrl = parts[0]; + const extraFragments = parts.slice(1).filter((f) => !isRecognizedFragment(f)); + return { baseUrl, extraFragments }; +} + export function buildFfmpegUrl(parsed: ParsedFfmpegUrl): string { let url = `ffmpeg:${parsed.baseUrl}`; - if (parsed.video !== "exclude") { - url += `#video=${parsed.video}`; + // Exclude is a primary-row sentinel meaning "no fragment for this track" — + // it's mutually exclusive with fallbacks. If the primary is exclude, emit + // nothing for that track regardless of trailing entries. + if (parsed.videos[0] !== "exclude") { + for (const v of parsed.videos) { + if (v === "exclude") continue; + url += `#video=${v}`; + } } - if (parsed.audio !== "exclude") { - url += `#audio=${parsed.audio}`; + if (parsed.audios[0] !== "exclude") { + for (const a of parsed.audios) { + if (a === "exclude") continue; + url += `#audio=${a}`; + } } if (parsed.hardware === "auto") { url += "#hardware"; + } else if (parsed.hardware !== "none") { + url += `#hardware=${parsed.hardware}`; } for (const frag of parsed.extraFragments) { url += `#${frag}`; @@ -131,7 +162,9 @@ export function toggleFfmpegMode(url: string, enable: boolean): string { return url; } - const withoutPrefix = url.slice(7); - const baseUrl = withoutPrefix.split("#")[0]; - return baseUrl; + // Preserve unknown fragments (e.g. #timeout=10) when leaving compat mode; + // only video/audio/hardware are go2rtc-ffmpeg directives that should be + // dropped along with the prefix. + const parsed = parseFfmpegUrl(url); + return [parsed.baseUrl, ...parsed.extraFragments].join("#"); } diff --git a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx index 1d5781ad0e..73fac7e9b0 100644 --- a/web/src/views/settings/Go2RtcStreamsSettingsView.tsx +++ b/web/src/views/settings/Go2RtcStreamsSettingsView.tsx @@ -10,15 +10,21 @@ import { LuEye, LuEyeOff, LuPencil, - LuPlus, + LuCirclePlus, + LuSlidersHorizontal, LuTrash2, + LuX, } from "react-icons/lu"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; 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, @@ -62,11 +68,13 @@ import { } from "@/utils/credentialMask"; import { parseFfmpegUrl, + parseFfmpegBaseAndExtras, buildFfmpegUrl, toggleFfmpegMode, type FfmpegVideoOption, type FfmpegAudioOption, type FfmpegHardwareOption, + type ParsedFfmpegUrl, } from "@/utils/go2rtcFfmpeg"; type RawPathsResponse = { @@ -365,7 +373,7 @@ export default function Go2RtcStreamsSettingsView({ variant="outline" className="my-4" > - + {t("go2rtcStreams.addStream")} @@ -703,7 +711,7 @@ function StreamCard({ -
+
{urls.map((url, urlIndex) => ( - + {t("go2rtcStreams.addUrl")}
@@ -764,7 +772,9 @@ function StreamUrlEntry({ const [isFocused, setIsFocused] = useState(false); const parsed = useMemo(() => parseFfmpegUrl(url), [url]); - const rawBaseUrl = parsed.isFfmpeg ? parsed.baseUrl : url; + const rawBaseUrl = parsed.isFfmpeg + ? [parsed.baseUrl, ...parsed.extraFragments].join("#") + : url; const canToggleCredentials = hasCredentials(rawBaseUrl) && !isMaskedPath(rawBaseUrl); @@ -778,15 +788,16 @@ function StreamUrlEntry({ }, [rawBaseUrl, showCredentials, isFocused]); const isTranscodingVideo = - parsed.isFfmpeg && parsed.video !== "copy" && parsed.video !== "exclude"; + parsed.isFfmpeg && parsed.videos.some((v) => v === "h264" || v === "h265"); const handleBaseUrlChange = useCallback( - (newBaseUrl: string) => { + (newInput: string) => { if (parsed.isFfmpeg) { - const newUrl = buildFfmpegUrl({ ...parsed, baseUrl: newBaseUrl }); + const { baseUrl, extraFragments } = parseFfmpegBaseAndExtras(newInput); + const newUrl = buildFfmpegUrl({ ...parsed, baseUrl, extraFragments }); onUpdateUrl(streamName, urlIndex, newUrl); } else { - onUpdateUrl(streamName, urlIndex, newBaseUrl); + onUpdateUrl(streamName, urlIndex, newInput); } }, [parsed, streamName, urlIndex, onUpdateUrl], @@ -800,212 +811,328 @@ function StreamUrlEntry({ [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 persistFfmpeg = useCallback( + (next: Partial) => { + const merged = { ...parsed, ...next }; + // Hardware acceleration is meaningless without a transcoding video codec + if (!merged.videos.some((v) => v === "h264" || v === "h265")) { + merged.hardware = "none"; } - const newUrl = buildFfmpegUrl(updated); - onUpdateUrl(streamName, urlIndex, newUrl); + onUpdateUrl(streamName, urlIndex, buildFfmpegUrl(merged)); }, [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]); + const updateVideoAt = useCallback( + (idx: number, value: FfmpegVideoOption) => { + // Picking exclude on the primary row drops any existing fallbacks — + // they have no meaning when the track is excluded entirely. + const videos = + idx === 0 && value === "exclude" + ? ["exclude" as FfmpegVideoOption] + : parsed.videos.map((v, i) => (i === idx ? value : v)); + persistFfmpeg({ videos }); + }, + [parsed.videos, persistFfmpeg], + ); + + const addVideo = useCallback(() => { + persistFfmpeg({ videos: [...parsed.videos, "copy"] }); + }, [parsed.videos, persistFfmpeg]); + + const removeVideoAt = useCallback( + (idx: number) => { + persistFfmpeg({ videos: parsed.videos.filter((_, i) => i !== idx) }); + }, + [parsed.videos, persistFfmpeg], + ); + + const updateAudioAt = useCallback( + (idx: number, value: FfmpegAudioOption) => { + // Picking exclude on the primary row drops any existing fallbacks — + // they have no meaning when the track is excluded entirely. + const audios = + idx === 0 && value === "exclude" + ? ["exclude" as FfmpegAudioOption] + : parsed.audios.map((a, i) => (i === idx ? value : a)); + persistFfmpeg({ audios }); + }, + [parsed.audios, persistFfmpeg], + ); + + const addAudio = useCallback(() => { + persistFfmpeg({ audios: [...parsed.audios, "copy"] }); + }, [parsed.audios, persistFfmpeg]); + + const removeAudioAt = useCallback( + (idx: number) => { + persistFfmpeg({ audios: parsed.audios.filter((_, i) => i !== idx) }); + }, + [parsed.audios, persistFfmpeg], + ); + + const updateHardware = useCallback( + (value: FfmpegHardwareOption) => { + persistFfmpeg({ hardware: value }); + }, + [persistFfmpeg], + ); + + const videoLabels: Record = { + copy: t("go2rtcStreams.ffmpeg.videoCopy"), + h264: t("go2rtcStreams.ffmpeg.videoH264"), + h265: t("go2rtcStreams.ffmpeg.videoH265"), + exclude: t("go2rtcStreams.ffmpeg.videoExclude"), + }; + const audioLabels: 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"), + }; + const hardwareLabels: Record = { + none: t("go2rtcStreams.ffmpeg.hardwareNone"), + auto: t("go2rtcStreams.ffmpeg.hardwareAuto"), + vaapi: t("go2rtcStreams.ffmpeg.hardwareVaapi"), + cuda: t("go2rtcStreams.ffmpeg.hardwareCuda"), + v4l2m2m: t("go2rtcStreams.ffmpeg.hardwareV4l2m2m"), + dxva2: t("go2rtcStreams.ffmpeg.hardwareDxva2"), + videotoolbox: t("go2rtcStreams.ffmpeg.hardwareVideotoolbox"), + }; return ( -
-
-
- handleBaseUrlChange(e.target.value)} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - placeholder={t("go2rtcStreams.streamUrlPlaceholder")} - /> - {canToggleCredentials && ( - - )} -
+
+
+ {t("go2rtcStreams.streamNumber", { index: urlIndex + 1 })} {canRemove && ( )}
- - {/* ffmpeg module toggle */} -
- - -
- - {/* ffmpeg options */} - {parsed.isFfmpeg && ( -
- {/* Video */} -
- - -
- - {/* Audio */} -
- - -
- - {/* Hardware acceleration - only when transcoding video */} - {isTranscodingVideo && ( -
- - handleBaseUrlChange(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + placeholder={t("go2rtcStreams.streamUrlPlaceholder")} + /> + {canToggleCredentials && ( +
- )} + {showCredentials || isFocused ? ( + + ) : ( + + )} + + )} +
+ + + + + + {t("go2rtcStreams.ffmpeg.useFfmpegModule")} + +
- )} + + {/* ffmpeg options */} + {parsed.isFfmpeg && ( +
+ {/* Video — one row per #video= fragment */} +
+
+ + {parsed.videos[0] !== "exclude" && ( + + )} +
+ {parsed.videos.map((v, idx) => ( +
+ + {idx > 0 ? ( + + ) : ( + // Reserve the same horizontal slot so the primary Select + // doesn't stretch wider than fallback rows. + + ))} +
+ + {/* Audio — one row per #audio= fragment */} +
+
+ + {parsed.audios[0] !== "exclude" && ( + + )} +
+ {parsed.audios.map((a, idx) => ( +
+ + {idx > 0 ? ( + + ) : ( + + ))} +
+ + {/* Hardware acceleration — only when transcoding video */} + {isTranscodingVideo && ( +
+
+ +
+ +
+ )} +
+ )} +
); }