Live streaming tech selection (#24374)

* allow users to select live streaming technology

* fix webrtc being downgraded to mse on load

`useUserPersistence` seeds state with the default and loads asynchronously, so the first render always used `mse` instead of the saved choice, and `useWebRTCGloballyAvailable` reports `checking` until the probe settles and re-enters that state on every consumer mount, so a saved `webrtc` was rewritten to `mse` even after the probe had already passed. On Safari the MSE player then timed out and latched the jsmpeg fallback. A pending probe now counts as available, the player waits on `autoLive` until the stored preferences load, and `handleError` gates on the mode in use since the fallback flag no longer implies webrtc is untried. A rejected IndexedDB read also resolves `loaded` now, so a blocked store can't leave the player waiting forever.

* add support for configurable ICE servers in WebRTC player

* add mic error state, fix dialog overwriting saved choice and dashboard ignoring stream

* tweaks
This commit is contained in:
Josh Hawkins
2026-09-16 09:12:06 -05:00
committed by GitHub
parent 79ca18d439
commit 64d6366ac4
26 changed files with 1940 additions and 283 deletions
+37 -28
View File
@@ -179,6 +179,9 @@ export default function LiveContextMenu({
],
);
const isForcedLowBandwidth =
groupStreamingSettings?.[camera]?.playerMode === "jsmpeg";
// ui
const audioControlsUsed = useRef(false);
@@ -270,12 +273,14 @@ export default function LiveContextMenu({
<div className="text-primary-variant smart-capitalize">
<CameraNameLabel camera={camera} />
</div>
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<div className="flex flex-row items-center gap-1">
<IoIosWarning className="mr-1 size-4 text-danger" />
<p className="mr-2 text-xs">{t("lowBandwidthMode")}</p>
</div>
)}
{preferredLiveMode == "jsmpeg" &&
isRestreamed &&
!isForcedLowBandwidth && (
<div className="flex flex-row items-center gap-1">
<IoIosWarning className="mr-1 size-4 text-danger" />
<p className="mr-2 text-xs">{t("lowBandwidthMode")}</p>
</div>
)}
</div>
{preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudio && (
<>
@@ -374,21 +379,23 @@ export default function LiveContextMenu({
</ContextMenuItem>
</>
)}
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
<div className="text-primary">
{t("button.reset", { ns: "common" })}
{preferredLiveMode == "jsmpeg" &&
isRestreamed &&
!isForcedLowBandwidth && (
<>
<ContextMenuSeparator />
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
<div className="text-primary">
{t("button.reset", { ns: "common" })}
</div>
</div>
</div>
</ContextMenuItem>
</>
)}
</ContextMenuItem>
</>
)}
{notificationsEnabledInConfig && isEnabled && (
<>
<ContextMenuSeparator />
@@ -548,14 +555,16 @@ export default function LiveContextMenu({
</ContextMenu>
<Dialog open={showSettings} onOpenChange={setShowSettings}>
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
streamMetadata={streamMetadata}
/>
{showSettings && (
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
streamMetadata={streamMetadata}
/>
)}
</Dialog>
</div>
);
@@ -174,7 +174,6 @@ export default function JSMpegPlayer({
streamType: "jsmpeg",
bandwidth: Math.round(bitrate),
totalFrames: frameCount,
latency: undefined,
droppedFrames: undefined,
decodedFrames: undefined,
droppedFrameRate: undefined,
+9 -2
View File
@@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import {
LivePlayerError,
TwoWayTalkError,
LivePlayerMode,
PlayerStatsType,
VideoResolutionType,
@@ -51,6 +52,7 @@ type LivePlayerProps = {
onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void;
onResetLiveMode?: () => void;
};
@@ -76,6 +78,7 @@ export default function LivePlayer({
onClick,
setFullResolution,
onError,
onMicrophoneError,
onResetLiveMode,
}: LivePlayerProps) {
const { t } = useTranslation(["components/player"]);
@@ -98,7 +101,6 @@ export default function LivePlayer({
const [stats, setStats] = useState<PlayerStatsType>({
streamType: "-",
bandwidth: 0, // in kBps
latency: undefined, // in seconds
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
@@ -272,6 +274,7 @@ export default function LivePlayer({
onPlaying={playerIsPlaying}
pip={pip}
onError={onError}
onMicrophoneError={onMicrophoneError}
/>
);
} else if (preferredLiveMode == "mse") {
@@ -363,7 +366,11 @@ export default function LivePlayer({
{cameraEnabled &&
!offline &&
(!showStillWithoutActivity || isReEnabling) &&
!liveReady && <ActivityIndicator />}
!liveReady && (
<div className="absolute inset-0 flex items-center justify-center">
<ActivityIndicator />
</div>
)}
{((showStillWithoutActivity && !liveReady) || liveReady) &&
objects.length > 0 && (
-11
View File
@@ -759,15 +759,6 @@ function MSEPlayer({
lastLoadedBytes = bytesLoaded;
lastTimestamp = now;
const latency =
video.seekable.length > 0
? Math.max(
0,
video.seekable.end(video.seekable.length - 1) -
video.currentTime,
)
: 0;
const videoQuality = video.getVideoPlaybackQuality();
const { totalVideoFrames, droppedVideoFrames } = videoQuality;
const droppedFrameRate = totalVideoFrames
@@ -777,7 +768,6 @@ function MSEPlayer({
setStats?.({
streamType: "MSE",
bandwidth,
latency,
totalFrames: totalVideoFrames,
droppedFrames: droppedVideoFrames || undefined,
decodedFrames: totalVideoFrames - droppedVideoFrames,
@@ -793,7 +783,6 @@ function MSEPlayer({
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
+7 -27
View File
@@ -8,27 +8,21 @@ type PlayerStatsProps = {
};
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
const { t } = useTranslation(["components/player"]);
const { t } = useTranslation(["components/player", "views/live"]);
const streamTypeLabel = t(
`stream.technology.name.${stats.streamType.toLowerCase()}`,
{ ns: "views/live", defaultValue: stats.streamType },
);
const fullStatsContent = (
<>
<p>
<span className="text-white/70">{t("stats.streamType.title")}</span>{" "}
<span className="text-white">{stats.streamType}</span>
<span className="text-white">{streamTypeLabel}</span>
</p>
<p>
<span className="text-white/70">{t("stats.bandwidth.title")}</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kBps</span>
</p>
{stats.latency != undefined && (
<p>
<span className="text-white/70">{t("stats.latency.title")}</span>{" "}
<span
className={`text-white ${stats.latency > 2 ? "text-danger" : ""}`}
>
{t("stats.latency.value", { seconds: stats.latency.toFixed(2) })}
</span>
</p>
)}
<p>
<span className="text-white/70">{t("stats.totalFrames")}</span>{" "}
<span className="text-white">{stats.totalFrames}</span>
@@ -62,26 +56,12 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
<div className="flex flex-row items-center justify-center gap-4">
<div className="flex flex-col items-center justify-start gap-1">
<span className="text-white/70">{t("stats.streamType.short")}</span>
<span className="text-white">{stats.streamType}</span>
<span className="text-white">{streamTypeLabel}</span>
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-white/70">{t("stats.bandwidth.short")}</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kBps</span>
</div>
{stats.latency != undefined && (
<div className="hidden flex-col items-center gap-1 md:flex">
<span className="text-white/70">
{t("stats.latency.short.title")}
</span>
<span
className={`text-white ${stats.latency >= 2 ? "text-danger" : ""}`}
>
{t("stats.latency.short.value", {
seconds: stats.latency.toFixed(2),
})}
</span>
</div>
)}
{stats.droppedFrames != undefined && (
<div className="flex flex-col items-center justify-end gap-1">
<span className="text-white/70">
@@ -0,0 +1,96 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { LivePlayerMode, WebRTCUnavailableReason } from "@/types/live";
import { LuX } from "react-icons/lu";
import { useTranslation } from "react-i18next";
type StreamTechnologySelectProps = {
value: LivePlayerMode;
onValueChange: (value: LivePlayerMode) => void;
isWebRTCAvailable: boolean;
webRTCUnavailableReason?: WebRTCUnavailableReason;
disabled: boolean;
};
export default function StreamTechnologySelect({
value,
onValueChange,
isWebRTCAvailable,
webRTCUnavailableReason,
disabled,
}: StreamTechnologySelectProps) {
const { t } = useTranslation(["views/live"]);
const isChecking = webRTCUnavailableReason === "checking";
return (
<Select
value={value}
onValueChange={onValueChange as (value: string) => void}
disabled={disabled}
>
<SelectTrigger className="w-full">
<span>{t(`stream.technology.name.${value}`)}</span>
</SelectTrigger>
<SelectContent className="w-[var(--radix-select-trigger-width)]">
<SelectItem value="mse">
<span className="flex flex-col gap-0.5 whitespace-normal">
<span>{t("stream.technology.name.mse")}</span>
<span className="text-xs text-muted-foreground">
{t("stream.technology.tips.mse")}
</span>
</span>
</SelectItem>
<SelectItem
value="webrtc"
disabled={!isWebRTCAvailable}
className="data-[disabled]:opacity-100"
>
<span className="flex flex-col gap-0.5 whitespace-normal">
<span className={cn(!isWebRTCAvailable && "opacity-50")}>
{t("stream.technology.name.webrtc")}
</span>
<span
className={cn(
"text-xs text-muted-foreground",
!isWebRTCAvailable && "opacity-50",
)}
>
{t("stream.technology.tips.webrtc")}
</span>
{!isWebRTCAvailable && webRTCUnavailableReason && (
<span
className={cn(
"mt-1 flex flex-row items-start gap-1.5 rounded-md border p-2",
isChecking
? "border-secondary-foreground/20 bg-secondary-foreground/10"
: "border-danger/20 bg-danger/10",
)}
>
{isChecking ? (
<ActivityIndicator
className="mt-0.5 size-3.5 shrink-0"
size={14}
/>
) : (
<LuX className="mt-0.5 size-3.5 shrink-0 text-danger" />
)}
<span className="text-xs text-secondary-foreground">
{t(
`stream.technology.unavailable.${webRTCUnavailableReason}`,
)}
</span>
</span>
)}
</span>
</SelectItem>
</SelectContent>
</Select>
);
}
+147 -60
View File
@@ -1,6 +1,13 @@
import { baseUrl } from "@/api/baseUrl";
import { LivePlayerError, PlayerStatsType } from "@/types/live";
import {
LivePlayerError,
PlayerStatsType,
TwoWayTalkError,
} from "@/types/live";
import { FrigateConfig } from "@/types/frigateConfig";
import { webRTCIceServers } from "@/utils/webrtcUtil";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
type WebRtcPlayerProps = {
className?: string;
@@ -15,6 +22,7 @@ type WebRtcPlayerProps = {
setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void;
onError?: (error: LivePlayerError) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void;
};
export default function WebRtcPlayer({
@@ -30,9 +38,22 @@ export default function WebRtcPlayer({
setStats,
onPlaying,
onError,
onMicrophoneError,
}: WebRtcPlayerProps) {
// metadata
const { data: config } = useSWR<FrigateConfig>("config");
// Keyed on the serialized list so an unrelated config update doesn't
// reconnect every WebRTC player.
const iceServersKey = JSON.stringify(
config?.go2rtc?.webrtc?.ice_servers ?? [],
);
const iceServers = useMemo(
() => webRTCIceServers(JSON.parse(iceServersKey)),
[iceServersKey],
);
const wsURL = useMemo(() => {
return `${baseUrl.replace(/^http/, "ws")}live/webrtc/api/ws?src=${camera}`;
}, [camera]);
@@ -54,6 +75,10 @@ export default function WebRtcPlayer({
const pcRef = useRef<RTCPeerConnection | undefined>(undefined);
const wsRef = useRef<WebSocket | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
// Separate sendonly-audio connection for two-way talk: go2rtc only wires the
// backchannel from a connection's initial offer.
const micPcRef = useRef<RTCPeerConnection | undefined>(undefined);
const micWsRef = useRef<WebSocket | null>(null);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const videoLoadTimeoutRef = useRef<NodeJS.Timeout>(undefined);
@@ -65,7 +90,7 @@ export default function WebRtcPlayer({
const pc = new RTCPeerConnection({
bundlePolicy: "max-bundle",
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
iceServers,
});
const localTracks = [];
@@ -105,7 +130,7 @@ export default function WebRtcPlayer({
videoRef.current.srcObject = new MediaStream(localTracks);
return pc;
},
[videoRef],
[videoRef, iceServers],
);
async function getMediaTracks(
@@ -123,51 +148,57 @@ export default function WebRtcPlayer({
}
}
// Offer/answer/ICE exchange over the WebSocket; shared by both connections.
const startSignaling = useCallback((pc: RTCPeerConnection, ws: WebSocket) => {
ws.addEventListener("open", () => {
pc.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) return;
ws.send(
JSON.stringify({
type: "webrtc/candidate",
value: ev.candidate.candidate,
}),
);
});
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer))
.then(() => {
ws.send(
JSON.stringify({
type: "webrtc/offer",
value: pc.localDescription?.sdp,
}),
);
});
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "webrtc/candidate") {
pc.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
} else if (msg.type === "webrtc/answer") {
pc.setRemoteDescription({ type: "answer", sdp: msg.value });
}
});
}, []);
const connect = useCallback(
async (aPc: Promise<RTCPeerConnection | undefined>) => {
if (!aPc) {
return;
}
pcRef.current = await aPc;
const pc = await aPc;
if (!pc) {
return;
}
pcRef.current = pc;
wsRef.current = new WebSocket(wsURL);
const ws = wsRef.current;
ws.addEventListener("open", () => {
pcRef.current?.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) return;
const msg = {
type: "webrtc/candidate",
value: ev.candidate.candidate,
};
ws.send(JSON.stringify(msg));
});
pcRef.current
?.createOffer()
.then((offer) => pcRef.current?.setLocalDescription(offer))
.then(() => {
const msg = {
type: "webrtc/offer",
value: pcRef.current?.localDescription?.sdp,
};
ws.send(JSON.stringify(msg));
});
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "webrtc/candidate") {
pcRef.current?.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
} else if (msg.type === "webrtc/answer") {
pcRef.current?.setRemoteDescription({
type: "answer",
sdp: msg.value,
});
}
});
startSignaling(pc, wsRef.current);
},
[wsURL],
[wsURL, startSignaling],
);
useEffect(() => {
@@ -179,9 +210,8 @@ export default function WebRtcPlayer({
return;
}
const aPc = PeerConnection(
microphoneEnabled ? "video+audio+microphone" : "video+audio",
);
// No mic here. It's a separate connection, so toggling talk never reloads.
const aPc = PeerConnection("video+audio");
connect(aPc);
return () => {
@@ -194,14 +224,80 @@ export default function WebRtcPlayer({
pcRef.current = undefined;
}
};
}, [camera, connect, PeerConnection, pcRef, videoRef, playbackEnabled]);
// Backchannel connection, alive only while the mic is on.
useEffect(() => {
if (!microphoneEnabled || !playbackEnabled) {
return;
}
let cancelled = false;
(async () => {
const tracks = await getMediaTracks("user", {
video: false,
audio: true,
});
if (cancelled) {
tracks.forEach((track) => track.stop());
return;
}
if (tracks.length === 0) {
onMicrophoneError?.("microphone");
return;
}
const pc = new RTCPeerConnection({
bundlePolicy: "max-bundle",
iceServers,
});
tracks.forEach((track) =>
pc.addTransceiver(track, { direction: "sendonly" }),
);
micPcRef.current = pc;
const ws = new WebSocket(wsURL);
micWsRef.current = ws;
startSignaling(pc, ws);
// go2rtc sends an error instead of an answer when it can't attach the
// microphone to the camera's backchannel.
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type !== "error" || cancelled) {
return;
}
// eslint-disable-next-line no-console
console.error(
`${camera} - Two-way talk error: ${msg.value} See the documentation: https://docs.frigate.video/configuration/live/#two-way-talk`,
);
onMicrophoneError?.("refused");
});
})();
return () => {
cancelled = true;
micPcRef.current?.getSenders().forEach((sender) => sender.track?.stop());
if (micWsRef.current) {
micWsRef.current.close();
micWsRef.current = null;
}
if (micPcRef.current) {
micPcRef.current.close();
micPcRef.current = undefined;
}
};
}, [
camera,
connect,
PeerConnection,
pcRef,
videoRef,
playbackEnabled,
microphoneEnabled,
playbackEnabled,
wsURL,
startSignaling,
iceServers,
camera,
onMicrophoneError,
]);
// ios compat
@@ -262,9 +358,7 @@ export default function WebRtcPlayer({
const report = await pcRef.current.getStats();
let bytesReceived = 0;
let timestamp = 0;
let roundTripTime = 0;
let framesReceived = 0;
let framesDropped = 0;
let framesDecoded = 0;
report.forEach((stat) => {
@@ -272,12 +366,8 @@ export default function WebRtcPlayer({
bytesReceived = stat.bytesReceived;
timestamp = stat.timestamp;
framesReceived = stat.framesReceived;
framesDropped = stat.framesDropped;
framesDecoded = stat.framesDecoded;
}
if (stat.type === "candidate-pair" && stat.state === "succeeded") {
roundTripTime = stat.currentRoundTripTime;
}
});
const timeDiff = (timestamp - lastTimestamp) / 1000; // in seconds
@@ -289,12 +379,10 @@ export default function WebRtcPlayer({
setStats?.({
streamType: "WebRTC",
bandwidth: Math.round(bitrate),
latency: roundTripTime,
totalFrames: framesReceived,
droppedFrames: framesDropped,
droppedFrames: undefined,
decodedFrames: framesDecoded,
droppedFrameRate:
framesReceived > 0 ? (framesDropped / framesReceived) * 100 : 0,
droppedFrameRate: undefined,
});
lastBytesReceived = bytesReceived;
@@ -307,7 +395,6 @@ export default function WebRtcPlayer({
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
@@ -30,11 +30,14 @@ import ActivityIndicator from "../indicators/activity-indicator";
import useSWR from "swr";
import { LuCheck, LuExternalLink, LuInfo, LuX } from "react-icons/lu";
import { Link } from "react-router-dom";
import { LiveStreamMetadata } from "@/types/live";
import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
import { Switch } from "@/components/ui/switch";
import { useWebRTCAvailableForStream } from "@/hooks/use-webrtc-availability";
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
type CameraStreamingDialogProps = {
camera: string;
@@ -55,7 +58,11 @@ export function CameraStreamingDialog({
setIsDialogOpen,
onSave,
}: CameraStreamingDialogProps) {
const { t } = useTranslation(["components/camera", "components/dialog"]);
const { t } = useTranslation([
"components/camera",
"components/dialog",
"views/live",
]);
const { getLocaleDocUrl } = useDocDomain();
const { data: config } = useSWR<FrigateConfig>("config");
@@ -68,6 +75,8 @@ export function CameraStreamingDialog({
Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] || "",
);
const [streamType, setStreamType] = useState<StreamType>("smart");
const [playerMode, setPlayerMode] = useState<LivePlayerMode>("mse");
const [forceLowBandwidth, setForceLowBandwidth] = useState(false);
const [compatibilityMode, setCompatibilityMode] = useState(false);
// metadata
@@ -79,13 +88,39 @@ export function CameraStreamingDialog({
[config, streamName],
);
const cameraMetadata = streamName ? streamMetadata?.[streamName] : undefined;
// Fetch the go2rtc stream metadata directly when the parent didn't provide it
// so codec/availability detection works regardless of caller
const { data: fetchedMetadata } = useSWR<LiveStreamMetadata>(
isRestreamed && streamName && !streamMetadata?.[streamName]
? `go2rtc/streams/${streamName}`
: null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
},
);
const cameraMetadata = streamName
? (streamMetadata?.[streamName] ?? fetchedMetadata)
: undefined;
const { audioOutput: supportsAudioOutput } = useMemo(
() => detectCameraAudioFeatures(cameraMetadata),
[cameraMetadata],
);
const { available: isWebRTCAvailable, reason: webRTCUnavailableReason } =
useWebRTCAvailableForStream(cameraMetadata, streamName);
// The chosen technology resolved for the currently selected stream, WITHOUT
// rewriting the saved choice
const resolvedPlayerMode = useMemo<LivePlayerMode>(
() => (playerMode === "webrtc" && !isWebRTCAvailable ? "mse" : playerMode),
[playerMode, isWebRTCAvailable],
);
// handlers
useEffect(() => {
@@ -107,10 +142,15 @@ export function CameraStreamingDialog({
setStreamName(streamExists ? streamNameFromSettings : firstStreamEntry);
setStreamType(cameraSettings.streamType || "smart");
const savedPlayerMode = cameraSettings.playerMode || "mse";
setPlayerMode(savedPlayerMode === "jsmpeg" ? "mse" : savedPlayerMode);
setForceLowBandwidth(savedPlayerMode === "jsmpeg");
setCompatibilityMode(cameraSettings.compatibilityMode || false);
} else {
setStreamName(firstStreamEntry);
setStreamType("smart");
setPlayerMode("mse");
setForceLowBandwidth(false);
setCompatibilityMode(false);
}
}, [groupStreamingSettings, camera, config]);
@@ -122,6 +162,7 @@ export function CameraStreamingDialog({
[camera]: {
streamName,
streamType,
playerMode: forceLowBandwidth ? "jsmpeg" : playerMode,
compatibilityMode,
playAudio: groupStreamingSettings?.[camera]?.playAudio ?? false,
volume: groupStreamingSettings?.[camera]?.volume ?? 1,
@@ -138,6 +179,8 @@ export function CameraStreamingDialog({
camera,
streamName,
streamType,
playerMode,
forceLowBandwidth,
compatibilityMode,
setIsDialogOpen,
onSave,
@@ -162,10 +205,15 @@ export function CameraStreamingDialog({
setStreamName(streamExists ? streamNameFromSettings : firstStreamEntry);
setStreamType(cameraSettings.streamType || "smart");
const savedPlayerMode = cameraSettings.playerMode || "mse";
setPlayerMode(savedPlayerMode === "jsmpeg" ? "mse" : savedPlayerMode);
setForceLowBandwidth(savedPlayerMode === "jsmpeg");
setCompatibilityMode(cameraSettings.compatibilityMode || false);
} else {
setStreamName(firstStreamEntry);
setStreamType("smart");
setPlayerMode("mse");
setForceLowBandwidth(false);
setCompatibilityMode(false);
}
@@ -234,7 +282,11 @@ export function CameraStreamingDialog({
<Label htmlFor="stream" className="text-right">
{t("group.camera.setting.stream")}
</Label>
<Select value={streamName} onValueChange={setStreamName}>
<Select
value={streamName}
onValueChange={setStreamName}
disabled={forceLowBandwidth}
>
<SelectTrigger className="">
<SelectValue
placeholder={t("group.camera.setting.placeholder")}
@@ -250,46 +302,86 @@ export function CameraStreamingDialog({
),
)}
</SelectContent>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("group.camera.setting.audioIsAvailable")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>{t("group.camera.setting.audioIsUnavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("group.camera.setting.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/live")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
{!forceLowBandwidth && (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>{t("group.camera.setting.audioIsAvailable")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>
{t("group.camera.setting.audioIsUnavailable")}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
{t("group.camera.setting.audio.tips.title")}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/live")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
)}
</Select>
</div>
)}
{isRestreamed &&
Object.entries(config?.cameras[camera].live.streams).length > 0 && (
<div className="flex flex-col items-start gap-2">
<Label htmlFor="streaming-technology" className="text-right">
{t("stream.mode", { ns: "views/live" })}
</Label>
<StreamTechnologySelect
value={resolvedPlayerMode}
onValueChange={setPlayerMode}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCUnavailableReason}
disabled={forceLowBandwidth}
/>
<p className="text-sm text-muted-foreground">
{t("stream.technology.description", { ns: "views/live" })}
</p>
</div>
)}
{isRestreamed &&
Object.entries(config?.cameras[camera].live.streams).length > 0 && (
<div className="flex flex-col items-start gap-2">
<div className="flex w-full items-center justify-between">
<Label htmlFor="force-low-bandwidth" className="cursor-pointer">
{t("stream.lowBandwidth.force.label", { ns: "views/live" })}
</Label>
<Switch
id="force-low-bandwidth"
checked={forceLowBandwidth}
onCheckedChange={setForceLowBandwidth}
/>
</div>
<p className="text-sm text-muted-foreground">
{t("stream.lowBandwidth.force.desc", { ns: "views/live" })}
</p>
</div>
)}
<div className="flex flex-col items-start gap-2">
<Label htmlFor="streaming-method" className="text-right">
{t("group.camera.setting.streamMethod.label")}
+78 -18
View File
@@ -4,11 +4,42 @@ import useSWR from "swr";
import { LivePlayerMode } from "@/types/live";
import useDeferredStreamMetadata from "./use-deferred-stream-metadata";
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
import {
evaluateStreamWebRTCAvailability,
useWebRTCGloballyAvailable,
} from "./use-webrtc-availability";
// Shared by the initial computation and the context-menu "Reset" so the two
// can't diverge.
function resolveLiveMode(
isRestreamed: boolean,
mseSupported: boolean,
webRTCAvailable: boolean,
requested: LivePlayerMode | undefined,
): LivePlayerMode {
let mode: LivePlayerMode;
if (!mseSupported) {
mode = isRestreamed && webRTCAvailable ? "webrtc" : "jsmpeg";
} else {
mode = isRestreamed ? "mse" : "jsmpeg";
}
if (requested === "jsmpeg") {
mode = "jsmpeg";
} else if (requested === "webrtc" && isRestreamed && webRTCAvailable) {
mode = "webrtc";
} else if (requested === "mse" && mseSupported && isRestreamed) {
mode = "mse";
}
return mode;
}
export default function useCameraLiveMode(
cameras: CameraConfig[],
windowVisible: boolean,
activeStreams?: { [cameraName: string]: string },
preferredModes?: { [cameraName: string]: LivePlayerMode | undefined },
) {
const { data: config } = useSWR<FrigateConfig>("config");
@@ -46,6 +77,25 @@ export default function useCameraLiveMode(
// Fetch stream metadata with deferred loading (doesn't block initial render)
const streamMetadata = useDeferredStreamMetadata(restreamedStreamNames);
const { globallyAvailable, globalReason } = useWebRTCGloballyAvailable();
// "checking" counts as usable because the probe re-enters it on every mount,
// and treating it as unavailable would downgrade a saved WebRTC choice.
const webRTCUsableStates = useMemo(() => {
const states: { [cameraName: string]: boolean } = {};
cameras.forEach((camera) => {
const streamName =
activeStreams?.[camera.name] ?? Object.values(camera.live.streams)[0];
const { available, reason } = evaluateStreamWebRTCAvailability({
globallyAvailable,
globalReason,
metadata: streamMetadata[streamName],
});
states[camera.name] = available || reason === "checking";
});
return states;
}, [cameras, activeStreams, globallyAvailable, globalReason, streamMetadata]);
// Compute live mode states
const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode;
@@ -81,11 +131,14 @@ export default function useCameraLiveMode(
newIsRestreamedStates[camera.name] = isRestreamed ?? false;
if (!mseSupported) {
newPreferredLiveModes[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
} else {
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
}
// Auto-selected default, overridden by the user's per-camera choice when viable
// Runtime fallback (player errors) still adjusts the mode on top of this base
newPreferredLiveModes[camera.name] = resolveLiveMode(
!!isRestreamed,
mseSupported,
webRTCUsableStates[camera.name] ?? false,
preferredModes?.[camera.name],
);
// Check each stream for audio support
if (isRestreamed) {
@@ -108,7 +161,15 @@ export default function useCameraLiveMode(
setPreferredLiveModes(newPreferredLiveModes);
setIsRestreamedStates(newIsRestreamedStates);
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
}, [activeStreams, cameras, config, windowVisible, streamMetadata]);
}, [
activeStreams,
cameras,
config,
windowVisible,
streamMetadata,
webRTCUsableStates,
preferredModes,
]);
const resetPreferredLiveMode = useCallback(
(cameraName: string) => {
@@ -124,19 +185,17 @@ export default function useCameraLiveMode(
config &&
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (!mseSupported) {
newModes[cameraName] = isRestreamed ? "webrtc" : "jsmpeg";
} else {
newModes[cameraName] = isRestreamed ? "mse" : "jsmpeg";
}
return newModes;
});
setPreferredLiveModes((prevModes) => ({
...prevModes,
[cameraName]: resolveLiveMode(
!!isRestreamed,
mseSupported,
webRTCUsableStates[cameraName] ?? false,
preferredModes?.[cameraName],
),
}));
},
[activeStreams, cameras, config],
[activeStreams, cameras, config, webRTCUsableStates, preferredModes],
);
return {
@@ -146,5 +205,6 @@ export default function useCameraLiveMode(
isRestreamedStates,
supportsAudioOutputStates,
streamMetadata,
webRTCUsableStates,
};
}
+6 -1
View File
@@ -185,7 +185,12 @@ export function useUserPersistence<S>(
setLoaded(true);
}
loadWithMigration();
// Consumers gate on this flag and the state already holds the defaults,
// so a rejected read must still resolve to "loaded".
loadWithMigration().catch(() => {
loadedKeyRef.current = namespacedKey;
setLoaded(true);
});
}, [
auth.isLoading,
isAuthenticated,
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { LiveStreamMetadata, WebRTCUnavailableReason } from "@/types/live";
import {
browserSupportsWebRTC,
browserSupportsWebRTCVideoCodec,
browserWebRTCVideoCodecs,
logWebRTCUnavailable,
resetWebRTCUnavailableLog,
webRTCIceServers,
} from "@/utils/webrtcUtil";
import {
getPlaybackAudioCodecs,
getStreamVideoCodecs,
} from "@/utils/cameraUtil";
import { probeWebRTCAvailability, resetWebRTCProbe } from "@/utils/webrtcProbe";
const WEBRTC_AUDIO_CODECS = new Set(["OPUS", "PCMA", "PCMU"]);
type GlobalAvailability = {
globallyAvailable: boolean;
globalReason: WebRTCUnavailableReason | null;
};
export type StreamAvailability = {
available: boolean;
reason?: WebRTCUnavailableReason;
detail?: string;
};
/** Split out of the hook so the decision can be exercised without React. */
export function evaluateStreamWebRTCAvailability(args: {
globallyAvailable: boolean;
globalReason: WebRTCUnavailableReason | null;
metadata: LiveStreamMetadata | null | undefined;
}): StreamAvailability {
const { globallyAvailable, globalReason, metadata } = args;
if (!globallyAvailable) {
return { available: false, reason: globalReason ?? "unreachable" };
}
// The metadata endpoint enumerates every producer variant including `#video=`
// transcodes, so an empty list means unknown rather than unplayable.
const videoCodecs = getStreamVideoCodecs(metadata);
if (
videoCodecs.length > 0 &&
!videoCodecs.some((c) => browserSupportsWebRTCVideoCodec(c))
) {
const browserCodecs = browserWebRTCVideoCodecs();
return {
available: false,
reason: "video-codec",
detail:
`Stream video codecs [${videoCodecs.join(", ")}] cannot be received over WebRTC in this browser ` +
`(browser supports: ${browserCodecs.length ? browserCodecs.join(", ") : "none"}).`,
};
}
// A stream with no playback audio is not disqualified.
const playbackAudioCodecs = getPlaybackAudioCodecs(metadata);
if (
playbackAudioCodecs.length > 0 &&
!playbackAudioCodecs.some((c) => WEBRTC_AUDIO_CODECS.has(c))
) {
return {
available: false,
reason: "audio-codec",
detail:
`Stream playback audio codecs [${playbackAudioCodecs.join(", ")}] cannot be carried over WebRTC ` +
`(supported: ${Array.from(WEBRTC_AUDIO_CODECS).join(", ")}).`,
};
}
return { available: true };
}
/** Console detail for a reason decided by the global availability check. */
function describeGlobalReason(
reason: WebRTCUnavailableReason,
testStream: string | undefined,
probeDetail: string | undefined,
): string {
switch (reason) {
case "browser":
return "This browser does not expose RTCPeerConnection.";
case "not-configured":
return "No candidates or ice_servers are set under go2rtc.webrtc.";
case "unreachable":
return `The connectivity probe against stream '${testStream}' failed: ${probeDetail ?? "unknown cause"}.`;
default:
return "";
}
}
/** go2rtc config the cached probe result and log messages belong to. */
let lastProbeSignature: string | null = null;
/**
* Once-per-session: browser support, go2rtc config, and a live handshake probe.
*/
export function useWebRTCGloballyAvailable(): GlobalAvailability {
const { data: config } = useSWR<FrigateConfig>("config");
const [probe, setProbe] = useState<{
state: "pending" | "pass" | "fail";
detail?: string;
}>({ state: "pending" });
const browserOk = browserSupportsWebRTC();
const configured = useMemo(() => {
const webrtc = config?.go2rtc?.webrtc;
if (!webrtc) return false;
return (
(webrtc.candidates?.length ?? 0) > 0 ||
(webrtc.ice_servers?.length ?? 0) > 0
);
}, [config]);
// Representative restreamed stream to probe against.
const testStream = useMemo(() => {
const streams = config?.go2rtc?.streams ?? {};
return Object.keys(streams)[0];
}, [config]);
const iceServers = useMemo(
() => webRTCIceServers(config?.go2rtc?.webrtc?.ice_servers),
[config],
);
// Identity of the cached page-session probe: a config change here must
// re-probe rather than return a stale result.
const probeSignature = useMemo(
() =>
JSON.stringify({
candidates: config?.go2rtc?.webrtc?.candidates ?? [],
iceServers,
testStream,
}),
[config, iceServers, testStream],
);
useEffect(() => {
// Runs on every consumer's mount, but the probe cache and log are page-wide:
// clearing them unconditionally would re-probe once per consumer.
if (lastProbeSignature !== probeSignature) {
lastProbeSignature = probeSignature;
resetWebRTCProbe();
resetWebRTCUnavailableLog();
}
setProbe({ state: "pending" });
}, [probeSignature]);
useEffect(() => {
if (!browserOk || !configured || !testStream) {
return;
}
let cancelled = false;
probeWebRTCAvailability(testStream, iceServers).then((result) => {
if (!cancelled) {
setProbe({
state: result.ok ? "pass" : "fail",
detail: result.detail,
});
}
});
return () => {
cancelled = true;
};
}, [browserOk, configured, testStream, iceServers]);
const availability = useMemo<GlobalAvailability>(() => {
if (!browserOk) {
return { globallyAvailable: false, globalReason: "browser" };
}
if (!configured) {
return { globallyAvailable: false, globalReason: "not-configured" };
}
if (probe.state === "pending") {
return { globallyAvailable: false, globalReason: "checking" };
}
if (probe.state === "fail") {
return { globallyAvailable: false, globalReason: "unreachable" };
}
return { globallyAvailable: true, globalReason: null };
}, [browserOk, configured, probe]);
useEffect(() => {
const reason = availability.globalReason;
// Before the config resolves, `configured` is false and the reason reads as
// not-configured, which would mislog on a correctly configured install.
if (!config || !reason || reason === "checking") {
return;
}
logWebRTCUnavailable(
undefined,
reason,
describeGlobalReason(reason, testStream, probe.detail),
);
}, [config, availability, testStream, probe.detail]);
return availability;
}
/** Per-stream WebRTC availability for selectors and auto-selection. */
export function useWebRTCAvailableForStream(
metadata: LiveStreamMetadata | null | undefined,
streamName?: string,
): StreamAvailability {
const { globallyAvailable, globalReason } = useWebRTCGloballyAvailable();
const availability = useMemo(
() =>
evaluateStreamWebRTCAvailability({
globallyAvailable,
globalReason,
metadata,
}),
[globallyAvailable, globalReason, metadata],
);
// Only stream-specific verdicts carry a detail; a global reason passing
// through here was already logged by useWebRTCGloballyAvailable.
useEffect(() => {
if (!availability.reason || !availability.detail) {
return;
}
logWebRTCUnavailable(streamName, availability.reason, availability.detail);
}, [availability, streamName]);
return availability;
}
+7
View File
@@ -1,5 +1,6 @@
import { IconName } from "@/components/icons/IconPicker";
import { TriggerAction, TriggerType } from "./trigger";
import { LivePlayerMode } from "./live";
export interface UiConfig {
timezone?: string;
@@ -359,6 +360,7 @@ export type StreamType = "no-streaming" | "smart" | "continuous";
export type CameraStreamingSettings = {
streamName: string;
streamType: StreamType;
playerMode?: LivePlayerMode;
compatibilityMode: boolean;
playAudio: boolean;
volume: number;
@@ -519,6 +521,11 @@ export interface FrigateConfig {
streams: Record<string, string | string[]>;
webrtc: {
candidates: string[];
ice_servers?: {
urls: string | string[];
username?: string;
credential?: string;
}[];
};
};
+10 -1
View File
@@ -33,6 +33,16 @@ export type LiveStreamMetadata = {
export type LivePlayerError = "stalled" | "startup" | "mse-decode";
export type TwoWayTalkError = "microphone" | "refused";
export type WebRTCUnavailableReason =
| "browser"
| "not-configured"
| "unreachable"
| "video-codec"
| "audio-codec"
| "checking";
export type AudioState = Record<string, boolean>;
export type StatsState = Record<string, boolean>;
export type VolumeState = Record<string, number>;
@@ -40,7 +50,6 @@ export type VolumeState = Record<string, number>;
export type PlayerStatsType = {
streamType: string;
bandwidth: number;
latency: number | undefined;
totalFrames: number;
droppedFrames: number | undefined;
decodedFrames: number | undefined;
+58 -2
View File
@@ -174,7 +174,7 @@ export function detectCameraAudioFeatures(
const twoWayAudio =
(!requireSecureContext || window.isSecureContext) &&
metadata.producers.find(
(metadata.producers ?? []).find(
(prod) =>
prod.medias &&
prod.medias.find((media) => media.includes("audio, sendonly")) !=
@@ -182,7 +182,7 @@ export function detectCameraAudioFeatures(
) != undefined;
const audioOutput =
metadata.producers.find(
(metadata.producers ?? []).find(
(prod) =>
prod.medias &&
prod.medias.find((media) => media.includes("audio, recvonly")) !=
@@ -195,6 +195,62 @@ export function detectCameraAudioFeatures(
};
}
const MEDIA_DIRECTIONS = new Set(["sendonly", "recvonly", "sendrecv"]);
// "sendonly" is the two-way-talk backchannel, not playback audio.
const PLAYBACK_DIRECTIONS = new Set(["recvonly", "sendrecv"]);
// Parses a go2rtc media line: "audio, recvonly, OPUS/48000/2" -> ["OPUS"],
// stripping the "/clockrate[/channels]" suffix.
function codecsFromMedia(media: string): string[] {
const parts = media.split(",").map((p) => p.trim());
return parts
.slice(1)
.filter((p) => !MEDIA_DIRECTIONS.has(p.toLowerCase()))
.map((p) => p.split("/")[0].toUpperCase());
}
function mediaDirection(media: string): string | undefined {
return media
.split(",")
.map((p) => p.trim().toLowerCase())
.find((p) => MEDIA_DIRECTIONS.has(p));
}
function codecsForKind(
metadata: LiveStreamMetadata | null | undefined,
kind: "video" | "audio",
directions?: Set<string>,
): string[] {
if (!metadata) return [];
const codecs = new Set<string>();
for (const producer of metadata.producers ?? []) {
for (const media of producer.medias ?? []) {
if (!media.trim().toLowerCase().startsWith(kind)) continue;
if (directions) {
const direction = mediaDirection(media);
if (!direction || !directions.has(direction)) continue;
}
codecsFromMedia(media).forEach((c) => codecs.add(c));
}
}
return Array.from(codecs);
}
export function getStreamVideoCodecs(
metadata: LiveStreamMetadata | null | undefined,
): string[] {
return codecsForKind(metadata, "video");
}
// go2rtc exposes a WebRTC-capable playback codec only when one is configured
// (e.g. an opus transcode) or the camera streams G.711 natively.
export function getPlaybackAudioCodecs(
metadata: LiveStreamMetadata | null | undefined,
): string[] {
return codecsForKind(metadata, "audio", PLAYBACK_DIRECTIONS);
}
const REPLAY_CAMERA_PREFIX = "_replay_";
/**
+1
View File
@@ -10,6 +10,7 @@ export type TransferSection = "layouts" | "streaming" | "preferences";
const cameraStreamingSettingsSchema = z.object({
streamName: z.string(),
streamType: z.enum(["no-streaming", "smart", "continuous"]),
playerMode: z.enum(["mse", "webrtc", "jsmpeg"]).optional().catch(undefined),
compatibilityMode: z.boolean(),
playAudio: z.boolean(),
volume: z.number(),
+140
View File
@@ -0,0 +1,140 @@
import { baseUrl } from "@/api/baseUrl";
/**
* Performs a single real WebRTC handshake against go2rtc to verify that a
* media connection can actually be established (validates candidates, port
* 8555 reachability, and STUN/TURN end-to-end). Result is cached per page
* session via a module-level promise.
*/
export type WebRTCProbeResult = {
ok: boolean;
detail?: string;
};
let probePromise: Promise<WebRTCProbeResult> | null = null;
function describeError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function runProbe(
testStream: string,
iceServers: RTCIceServer[],
timeoutMs: number,
): Promise<WebRTCProbeResult> {
return new Promise<WebRTCProbeResult>((resolve) => {
let settled = false;
const wsURL = `${baseUrl.replace(/^http/, "ws")}live/webrtc/api/ws?src=${testStream}`;
const pc = new RTCPeerConnection({
bundlePolicy: "max-bundle",
iceServers,
});
let ws: WebSocket | null = null;
const cleanup = (result: WebRTCProbeResult) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
ws?.close();
} catch {
// ignore
}
try {
pc.close();
} catch {
// ignore
}
resolve(result);
};
const fail = (detail: string) => cleanup({ ok: false, detail });
const timer = setTimeout(
() => fail(`no ICE connection within ${timeoutMs}ms`),
timeoutMs,
);
pc.oniceconnectionstatechange = () => {
const state = pc.iceConnectionState;
if (state === "connected" || state === "completed") {
cleanup({ ok: true });
} else if (state === "failed" || state === "closed") {
fail(`ICE connection state: ${state}`);
}
};
pc.addTransceiver("video", { direction: "recvonly" });
try {
ws = new WebSocket(wsURL);
} catch (err) {
fail(`WebSocket to go2rtc could not be opened: ${describeError(err)}`);
return;
}
ws.addEventListener("error", () => fail("WebSocket to go2rtc errored"));
ws.addEventListener("open", () => {
pc.addEventListener("icecandidate", (ev) => {
if (!ev.candidate || !ws) return;
ws.send(
JSON.stringify({
type: "webrtc/candidate",
value: ev.candidate.candidate,
}),
);
});
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer))
.then(() => {
ws?.send(
JSON.stringify({
type: "webrtc/offer",
value: pc.localDescription?.sdp,
}),
);
})
.catch((err) =>
fail(`failed to create the local offer: ${describeError(err)}`),
);
});
ws.addEventListener("message", (ev) => {
let msg: { type: string; value: string };
try {
msg = JSON.parse((ev as MessageEvent).data);
} catch {
return;
}
if (msg.type === "webrtc/candidate") {
pc.addIceCandidate({ candidate: msg.value, sdpMid: "0" }).catch((err) =>
fail(`remote ICE candidate rejected: ${describeError(err)}`),
);
} else if (msg.type === "webrtc/answer") {
pc.setRemoteDescription({ type: "answer", sdp: msg.value }).catch(
(err) => fail(`remote answer rejected: ${describeError(err)}`),
);
}
});
});
}
export function probeWebRTCAvailability(
testStream: string,
iceServers: RTCIceServer[],
timeoutMs: number = 5000,
): Promise<WebRTCProbeResult> {
if (!probePromise) {
probePromise = runProbe(testStream, iceServers, timeoutMs);
}
return probePromise;
}
/** Clears the cached probe result (e.g. when go2rtc config changes). */
export function resetWebRTCProbe(): void {
probePromise = null;
}
+108
View File
@@ -0,0 +1,108 @@
import { FrigateConfig } from "@/types/frigateConfig";
import { WebRTCUnavailableReason } from "@/types/live";
/**
* Helpers for detecting what the current browser can do over WebRTC.
* Used to gate the WebRTC live streaming option (e.g. H.265 is only
* decodable over WebRTC on Chrome 136+ / Safari 18+ with HEVC hardware).
*/
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
{ urls: "stun:stun.l.google.com:19302" },
];
type ConfiguredIceServers = FrigateConfig["go2rtc"]["webrtc"]["ice_servers"];
/** ICE servers for browser peer connections, falling back to public STUN. */
export function webRTCIceServers(
configured: ConfiguredIceServers,
): RTCIceServer[] {
if (!configured?.length) {
return DEFAULT_ICE_SERVERS;
}
return configured.map((server) => ({
urls: server.urls,
username: server.username,
credential: server.credential,
}));
}
export function browserSupportsWebRTC(): boolean {
return typeof window !== "undefined" && "RTCPeerConnection" in window;
}
/** Codec aliases that refer to the same underlying codec. */
const CODEC_ALIASES: Record<string, string> = {
HEVC: "H265",
AVC: "H264",
};
function normalizeCodec(codec: string): string {
const upper = codec.toUpperCase();
return CODEC_ALIASES[upper] ?? upper;
}
/** The set of video codec names the browser can receive over WebRTC. */
export function browserWebRTCVideoCodecs(): string[] {
if (
typeof RTCRtpReceiver === "undefined" ||
typeof RTCRtpReceiver.getCapabilities !== "function"
) {
return [];
}
const caps = RTCRtpReceiver.getCapabilities("video");
if (!caps) return [];
const codecs = new Set<string>();
for (const codec of caps.codecs) {
// mimeType is like "video/H264"; ignore infra codecs (rtx, red, ulpfec)
const name = codec.mimeType.split("/")[1]?.toUpperCase();
if (!name || ["RTX", "RED", "ULPFEC", "FLEXFEC-03"].includes(name)) {
continue;
}
codecs.add(normalizeCodec(name));
}
return Array.from(codecs);
}
export function browserSupportsWebRTCVideoCodec(codec: string): boolean {
return browserWebRTCVideoCodecs().includes(normalizeCodec(codec));
}
const DOCS_URL =
"https://docs.frigate.video/configuration/live/#selecting-a-streaming-technology";
// Availability is consumed by several components at once, so without this the
// same fact prints once per consumer.
const loggedMessages = new Set<string>();
/**
* Logs why WebRTC is unavailable, supplementing the short message shown inline
* in the technology selector with the underlying detail.
*
* @param scope - Stream name for per-stream reasons, omitted for global ones
* @param reason - The reason surfaced in the UI
* @param description - The detail the UI message leaves out
*/
export function logWebRTCUnavailable(
scope: string | undefined,
reason: WebRTCUnavailableReason,
description: string,
): void {
const message = `${scope ? `${scope} - ` : ""}WebRTC unavailable '${reason}': ${description} See the documentation: ${DOCS_URL}`;
if (loggedMessages.has(message)) {
return;
}
loggedMessages.add(message);
// eslint-disable-next-line no-console
console.warn(message);
}
/** Clears the emitted messages (e.g. when go2rtc config changes). */
export function resetWebRTCUnavailableLog(): void {
loggedMessages.clear();
}
+4 -15
View File
@@ -22,6 +22,7 @@ import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
import {
AudioState,
LivePlayerError,
LivePlayerMode,
LiveStreamMetadata,
StatsState,
@@ -65,9 +66,7 @@ type DraggableGridLayoutProps = {
fullscreen: boolean;
toggleFullscreen: () => void;
preferredLiveModes: { [key: string]: LivePlayerMode };
setPreferredLiveModes: React.Dispatch<
React.SetStateAction<{ [key: string]: LivePlayerMode }>
>;
handleError: (cameraName: string, error: LivePlayerError) => void;
resetPreferredLiveMode: (cameraName: string) => void;
isRestreamedStates: { [key: string]: boolean };
supportsAudioOutputStates: {
@@ -89,7 +88,7 @@ export default function DraggableGridLayout({
fullscreen,
toggleFullscreen,
preferredLiveModes,
setPreferredLiveModes,
handleError,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
@@ -672,17 +671,7 @@ export default function DraggableGridLayout({
onSelectCamera(camera.name);
}
}}
onError={(e) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (e === "mse-decode") {
newModes[camera.name] = "webrtc";
} else {
newModes[camera.name] = "jsmpeg";
}
return newModes;
});
}}
onError={(e) => handleError(camera.name, e)}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name]}
+250 -53
View File
@@ -12,6 +12,7 @@ import {
import CameraFeatureToggle from "@/components/dynamic/CameraFeatureToggle";
import FilterSwitch from "@/components/filter/FilterSwitch";
import LivePlayer from "@/components/player/LivePlayer";
import StreamTechnologySelect from "@/components/player/StreamTechnologySelect";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import {
@@ -26,11 +27,18 @@ import {
} from "@/components/ui/popover";
import { useResizeObserver } from "@/hooks/resize-observer";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import {
useWebRTCAvailableForStream,
useWebRTCGloballyAvailable,
} from "@/hooks/use-webrtc-availability";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import {
LivePlayerError,
TwoWayTalkError,
LivePlayerMode,
LiveStreamMetadata,
VideoResolutionType,
WebRTCUnavailableReason,
} from "@/types/live";
import { RecordingStartingPoint } from "@/types/record";
import React, {
@@ -158,6 +166,23 @@ export default function LiveCameraView({
Object.values(camera.live.streams)[0],
);
const [
userPreferredLiveMode,
setUserPreferredLiveMode,
userPreferredLiveModeLoaded,
] = useUserPersistence<LivePlayerMode>(
`${camera.name}-preferred-live-mode`,
"mse",
);
const [forceLowBandwidth, setForceLowBandwidth, forceLowBandwidthLoaded] =
useUserPersistence<boolean>(`${camera.name}-force-low-bandwidth`, false);
// useUserPersistence seeds state with the defaults and loads asynchronously,
// so until this is true the values above are not the user's choices.
const preferencesLoaded =
streamNameLoaded && userPreferredLiveModeLoaded && forceLowBandwidthLoaded;
const isRestreamed = useMemo(
() =>
config &&
@@ -188,6 +213,43 @@ export default function LiveCameraView({
},
);
const webRTCAvailability = useWebRTCAvailableForStream(
cameraMetadata,
streamName,
);
const isWebRTCAvailable = webRTCAvailability.available;
// Two-way talk is the sendonly backchannel: global, not per-stream.
const { globallyAvailable: webRTCGloballyAvailable } =
useWebRTCGloballyAvailable();
// "checking" means the probe has not answered yet, and it re-enters that on
// every mount, so treating it as unavailable downgrades the saved choice.
const webRTCVerdictPending = webRTCAvailability.reason === "checking";
const webRTCUsable = isWebRTCAvailable || webRTCVerdictPending;
// Resolves the saved preference without overwriting it. Transient error
// fallbacks layer on top in preferredLiveMode.
const resolvedUserMode = useMemo<LivePlayerMode>(() => {
const mseSupported =
"MediaSource" in window || "ManagedMediaSource" in window;
if (!isRestreamed) {
return "jsmpeg";
}
// jsmpeg is the force low-bandwidth switch, not a technology choice.
const requested =
!userPreferredLiveMode || userPreferredLiveMode === "jsmpeg"
? "mse"
: userPreferredLiveMode;
if (requested === "webrtc" && !webRTCUsable) {
return mseSupported ? "mse" : "jsmpeg";
}
if (requested === "mse" && !mseSupported) {
return webRTCUsable ? "webrtc" : "jsmpeg";
}
return requested;
}, [userPreferredLiveMode, webRTCUsable, isRestreamed]);
const { twoWayAudio: supports2WayTalk, audioOutput: supportsAudioOutput } =
useMemo(() => detectCameraAudioFeatures(cameraMetadata), [cameraMetadata]);
@@ -362,11 +424,15 @@ export default function LiveCameraView({
});
const preferredLiveMode = useMemo(() => {
if (mic) {
if (mic && isWebRTCAvailable) {
return "webrtc";
}
if (webRTC && isRestreamed) {
if (forceLowBandwidth) {
return "jsmpeg";
}
if (webRTC && isRestreamed && isWebRTCAvailable) {
return "webrtc";
}
@@ -378,16 +444,37 @@ export default function LiveCameraView({
return "jsmpeg";
}
if (!("MediaSource" in window || "ManagedMediaSource" in window)) {
if (
!("MediaSource" in window || "ManagedMediaSource" in window) &&
isWebRTCAvailable
) {
return "webrtc";
}
if (!isRestreamed) {
return "jsmpeg";
}
return resolvedUserMode;
}, [
lowBandwidth,
forceLowBandwidth,
mic,
webRTC,
isRestreamed,
resolvedUserMode,
isWebRTCAvailable,
]);
return "mse";
}, [lowBandwidth, mic, webRTC, isRestreamed]);
// A latched error fallback would keep overriding the user's new choice.
useEffect(() => {
setWebRTC(false);
setLowBandwidth(false);
}, [userPreferredLiveMode, streamName, forceLowBandwidth]);
// A fallback chosen before the verdict arrived was made on incomplete info.
useEffect(() => {
if (!webRTCVerdictPending) {
setWebRTC(false);
setLowBandwidth(false);
}
}, [webRTCVerdictPending]);
useKeyboardListener(["m", "Escape"], (key, modifiers) => {
if (!modifiers.down) {
@@ -499,11 +586,9 @@ export default function LiveCameraView({
const handleError = useCallback(
(e: LivePlayerError) => {
if (e) {
if (
!webRTC &&
config &&
config.go2rtc?.webrtc?.candidates?.length > 0
) {
// WebRTC can now be the user's own choice, so the fallback flag no
// longer implies untried: hopping to the mode that just failed sticks.
if (preferredLiveMode !== "webrtc" && webRTCUsable) {
setWebRTC(true);
} else {
setWebRTC(false);
@@ -511,7 +596,15 @@ export default function LiveCameraView({
}
}
},
[config, webRTC],
[preferredLiveMode, webRTCUsable],
);
const handleMicrophoneError = useCallback(
(error: TwoWayTalkError) => {
setMic(false);
toast.error(t(`twoWayTalk.error.${error}`), { position: "top-center" });
},
[t],
);
return (
@@ -644,9 +737,11 @@ export default function LiveCameraView({
Icon={mic ? FaMicrophone : FaMicrophoneSlash}
isActive={mic}
title={
mic
? t("twoWayTalk.disable", { ns: "views/live" })
: t("twoWayTalk.enable", { ns: "views/live" })
!webRTCGloballyAvailable
? t("twoWayTalk.requiresWebRTC", { ns: "views/live" })
: mic
? t("twoWayTalk.disable", { ns: "views/live" })
: t("twoWayTalk.enable", { ns: "views/live" })
}
onClick={() => {
setMic(!mic);
@@ -654,7 +749,7 @@ export default function LiveCameraView({
setAudio(true);
}
}}
disabled={!cameraEnabled || debug}
disabled={!cameraEnabled || debug || !webRTCGloballyAvailable}
/>
)}
{supportsAudioOutput && preferredLiveMode != "jsmpeg" && (
@@ -683,12 +778,18 @@ export default function LiveCameraView({
fullscreen={fullscreen}
streamName={streamName ?? ""}
setStreamName={setStreamName}
userPreferredLiveMode={resolvedUserMode}
setUserPreferredLiveMode={setUserPreferredLiveMode}
forceLowBandwidth={forceLowBandwidth ?? false}
setForceLowBandwidth={setForceLowBandwidth}
preferredLiveMode={preferredLiveMode}
playInBackground={playInBackground ?? false}
setPlayInBackground={setPlayInBackground}
showStats={showStats}
setShowStats={setShowStats}
isRestreamed={isRestreamed ?? false}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCAvailability.reason}
setLowBandwidth={setLowBandwidth}
supportsAudioOutput={supportsAudioOutput}
supports2WayTalk={supports2WayTalk}
@@ -770,12 +871,14 @@ export default function LiveCameraView({
micEnabled={mic}
iOSCompatFullScreen={isIOS}
preferredLiveMode={preferredLiveMode}
autoLive={preferencesLoaded}
useWebGL={true}
streamName={streamName ?? ""}
pip={pip}
containerRef={containerRef}
setFullResolution={setFullResolution}
onError={handleError}
onMicrophoneError={handleMicrophoneError}
/>
</div>
</TransformComponent>
@@ -830,12 +933,18 @@ type FrigateCameraFeaturesProps = {
fullscreen: boolean;
streamName: string;
setStreamName?: (value: string | undefined) => void;
preferredLiveMode: string;
userPreferredLiveMode: LivePlayerMode;
setUserPreferredLiveMode: (value: LivePlayerMode | undefined) => void;
forceLowBandwidth: boolean;
setForceLowBandwidth: (value: boolean | undefined) => void;
preferredLiveMode: LivePlayerMode;
playInBackground: boolean;
setPlayInBackground: (value: boolean | undefined) => void;
showStats: boolean;
setShowStats: (value: boolean) => void;
isRestreamed: boolean;
isWebRTCAvailable: boolean;
webRTCUnavailableReason?: WebRTCUnavailableReason;
setLowBandwidth: React.Dispatch<React.SetStateAction<boolean>>;
supportsAudioOutput: boolean;
supports2WayTalk: boolean;
@@ -852,12 +961,18 @@ function FrigateCameraFeatures({
fullscreen,
streamName,
setStreamName,
userPreferredLiveMode,
setUserPreferredLiveMode,
forceLowBandwidth,
setForceLowBandwidth,
preferredLiveMode,
playInBackground,
setPlayInBackground,
showStats,
setShowStats,
isRestreamed,
isWebRTCAvailable,
webRTCUnavailableReason,
setLowBandwidth,
supportsAudioOutput,
supports2WayTalk,
@@ -890,6 +1005,10 @@ function FrigateCameraFeatures({
const isAdmin = useIsAdmin();
const streamSelectLabel = Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
);
// manual event
const recordingEventIdRef = useRef<string | null>(null);
@@ -1235,17 +1354,13 @@ function FrigateCameraFeatures({
</Label>
<Select
value={streamName}
disabled={debug}
disabled={debug || forceLowBandwidth}
onValueChange={(value) => {
setStreamName?.(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue>
{Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
)}
</SelectValue>
<SelectValue>{streamSelectLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -1367,6 +1482,8 @@ function FrigateCameraFeatures({
)}
{preferredLiveMode == "jsmpeg" &&
userPreferredLiveMode != "jsmpeg" &&
!forceLowBandwidth &&
!debug &&
isRestreamed && (
<div className="flex flex-col items-center gap-3">
@@ -1393,6 +1510,55 @@ function FrigateCameraFeatures({
)}
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-1">
<Label htmlFor="streaming-mode">{t("stream.mode")}</Label>
<StreamTechnologySelect
value={userPreferredLiveMode}
onValueChange={setUserPreferredLiveMode}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCUnavailableReason}
disabled={debug || forceLowBandwidth}
/>
{debug ? (
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>{t("stream.debug.technology")}</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
{t("stream.technology.description")}
</p>
)}
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Label
className="mx-0 cursor-pointer text-primary"
htmlFor="forcelowbandwidth"
>
{t("stream.lowBandwidth.force.label")}
</Label>
<Switch
className="ml-1"
id="forcelowbandwidth"
disabled={debug}
checked={forceLowBandwidth}
onCheckedChange={(checked) =>
setForceLowBandwidth(checked)
}
/>
</div>
<p className="text-sm text-muted-foreground">
{t("stream.lowBandwidth.force.desc")}
</p>
</div>
)}
{isRestreamed && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
@@ -1600,14 +1766,10 @@ function FrigateCameraFeatures({
onValueChange={(value) => {
setStreamName?.(value);
}}
disabled={debug}
disabled={debug || forceLowBandwidth}
>
<SelectTrigger className="w-full">
<SelectValue>
{Object.keys(camera.live.streams).find(
(key) => camera.live.streams[key] === streamName,
)}
</SelectValue>
<SelectValue>{streamSelectLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -1725,29 +1887,64 @@ function FrigateCameraFeatures({
)}
</div>
)}
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<div className="mt-2 flex flex-col items-center gap-3">
<div className="flex flex-row items-center gap-2">
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
{t("stream.lowBandwidth.tips")}
</p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
disabled={debug}
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
{preferredLiveMode == "jsmpeg" &&
userPreferredLiveMode != "jsmpeg" &&
!forceLowBandwidth &&
isRestreamed && (
<div className="mt-2 flex flex-col items-center gap-3">
<div className="flex flex-row items-center gap-2">
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
{t("stream.lowBandwidth.tips")}
</p>
</div>
</Button>
</div>
)}
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
disabled={debug}
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
</div>
</Button>
</div>
)}
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="px-2">
<div className="mb-1 text-sm">{t("stream.mode")}</div>
<StreamTechnologySelect
value={userPreferredLiveMode}
onValueChange={setUserPreferredLiveMode}
isWebRTCAvailable={isWebRTCAvailable}
webRTCUnavailableReason={webRTCUnavailableReason}
disabled={debug || forceLowBandwidth}
/>
<p className="mt-1 text-sm text-muted-foreground">
{t("stream.technology.description")}
</p>
</div>
)}
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-2">
<FilterSwitch
label={t("stream.lowBandwidth.force.label")}
isChecked={forceLowBandwidth}
onCheckedChange={(checked) =>
setForceLowBandwidth(checked)
}
disabled={debug}
/>
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
{t("stream.lowBandwidth.force.desc")}
</p>
</div>
)}
<div className="flex flex-col gap-1 px-2">
+16 -4
View File
@@ -43,6 +43,7 @@ import { cn } from "@/lib/utils";
import {
AudioState,
LivePlayerError,
LivePlayerMode,
StatsState,
VolumeState,
} from "@/types/live";
@@ -271,6 +272,16 @@ export default function LiveDashboardView({
return streams;
}, [cameras, currentGroupStreamingSettings]);
// Per-camera streaming-technology choice from the camera group settings.
const preferredModes = useMemo(() => {
const modes: { [cameraName: string]: LivePlayerMode | undefined } = {};
cameras.forEach((camera) => {
modes[camera.name] =
currentGroupStreamingSettings?.[camera.name]?.playerMode;
});
return modes;
}, [cameras, currentGroupStreamingSettings]);
const {
preferredLiveModes,
setPreferredLiveModes,
@@ -278,7 +289,8 @@ export default function LiveDashboardView({
isRestreamedStates,
supportsAudioOutputStates,
streamMetadata,
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
webRTCUsableStates,
} = useCameraLiveMode(cameras, windowVisible, activeStreams, preferredModes);
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@@ -286,7 +298,7 @@ export default function LiveDashboardView({
(cameraName: string, error: LivePlayerError) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (error === "mse-decode") {
if (error === "mse-decode" && webRTCUsableStates[cameraName]) {
newModes[cameraName] = "webrtc";
} else {
newModes[cameraName] = "jsmpeg";
@@ -294,7 +306,7 @@ export default function LiveDashboardView({
return newModes;
});
},
[setPreferredLiveModes],
[setPreferredLiveModes, webRTCUsableStates],
);
// audio states
@@ -673,7 +685,7 @@ export default function LiveDashboardView({
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
preferredLiveModes={preferredLiveModes}
setPreferredLiveModes={setPreferredLiveModes}
handleError={handleError}
resetPreferredLiveMode={resetPreferredLiveMode}
isRestreamedStates={isRestreamedStates}
supportsAudioOutputStates={supportsAudioOutputStates}