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