probe webrtc before reporting it as not configured

The WebRTC gate read only the Frigate config, so an install with no go2rtc candidates showed WebRTC as not configured, even though go2rtc fills in default candidates at startup (including the internal one under the HA app) and WebRTC often worked. Unset candidates now run the connectivity probe first, and only a failed probe reports not configured. The not-configured reason shows as a neutral note that names the setting and says WebRTC is optional, rather than as a red error.
This commit is contained in:
Josh Hawkins
2026-10-15 21:16:44 -05:00
parent 3cd4c9c55c
commit 87a7551aaa
5 changed files with 106 additions and 12 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ Frigate [defaults to MSE](#why-does-frigate-prefer-mse-over-webrtc-for-live-view
- The choices are saved **per device, per camera** in your browser's local storage. - The choices are saved **per device, per camera** in your browser's local storage.
- **WebRTC is only selectable when it can actually work for that stream.** When it can't, the option is shown disabled with the reason inline, and a more detailed reason (the failing codecs, or why the connectivity check failed) is logged to your browser's console. Common reasons: - **WebRTC is only selectable when it can actually work for that stream.** When it can't, the option is shown disabled with the reason inline, and a more detailed reason (the failing codecs, or why the connectivity check failed) is logged to your browser's console. Common reasons:
- **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc` (see [WebRTC extra configuration](#webrtc-extra-configuration)). - **Not configured**: no `candidates` or `ice_servers` are set under `go2rtc.webrtc`, and the connectivity check failed with go2rtc's default candidates (see [WebRTC extra configuration](#webrtc-extra-configuration)). This is a setup note rather than an error, since live view works without WebRTC.
- **Could not connect**: e.g. port `8555` isn't reachable, or a STUN/TURN server is misconfigured. Frigate runs a one-time WebRTC connectivity check when the Live view opens; the option may briefly show as "checking" while it runs. - **Could not connect**: e.g. port `8555` isn't reachable, or a STUN/TURN server is misconfigured. Frigate runs a one-time WebRTC connectivity check when the Live view opens; the option may briefly show as "checking" while it runs.
- **Unsupported video codec**: the stream's video codec can't be played over WebRTC in your browser, most commonly H.265/HEVC in Firefox or Edge. - **Unsupported video codec**: the stream's video codec can't be played over WebRTC in your browser, most commonly H.265/HEVC in Firefox or Edge.
- **Unsupported audio codec**: WebRTC needs opus or G.711 audio, so a stream whose playback audio is only AAC (without an added opus/G.711 track) can't carry audio over WebRTC. See [Audio Support](#audio-support) for how to add one. - **Unsupported audio codec**: WebRTC needs opus or G.711 audio, so a stream whose playback audio is only AAC (without an added opus/G.711 track) can't carry audio over WebRTC. See [Audio Support](#audio-support) for how to add one.
@@ -110,6 +110,15 @@ test.describe("WebRTC availability gating @critical", () => {
// distinguishes enabled from disabled options. // distinguishes enabled from disabled options.
const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ }); const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ });
await expect(mseOption).not.toHaveAttribute("aria-disabled", "true"); await expect(mseOption).not.toHaveAttribute("aria-disabled", "true");
// a missing setting, shown as a neutral note rather than an error
await expect(webrtcOption).toContainText(
"under go2rtc → webrtc → candidates",
);
await expect(webrtcOption).toContainText(
"Optional: live view works without it.",
);
await expect(webrtcOption.locator(".text-danger")).toHaveCount(0);
}); });
test("the unavailable reason is logged to the console once", async ({ test("the unavailable reason is logged to the console once", async ({
@@ -214,6 +223,62 @@ test.describe("WebRTC availability gating @critical", () => {
await expect(webrtcOption).toContainText(/Checking WebRTC availability/i); await expect(webrtcOption).toContainText(/Checking WebRTC availability/i);
}); });
test("desktop: unset candidates run the probe before reporting not configured", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Desktop dropdown only");
// Count probe connections. The probe fails fast against the preview
// server, which with go2rtc's default candidates means setup is missing.
let probes = 0;
await frigateApp.page.routeWebSocket("**/live/webrtc/api/ws**", (ws) => {
probes++;
ws.close();
});
await frigateApp.installDefaults({
config: {
go2rtc: {
streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] },
},
},
});
await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) =>
route.fulfill({
json: {
producers: [{ medias: ["video, recvonly, H264"] }],
consumers: [],
},
}),
);
await frigateApp.goto("/#front_door");
const live = new LivePage(frigateApp.page, true);
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
await frigateApp.page.locator("button:has(svg)").last().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
await menu
.locator('div:has(> label[for="streaming-mode"]) [role="combobox"]')
.first()
.click();
const webrtcOption = frigateApp.page.getByRole("option", {
name: /WebRTC/,
});
await expect(webrtcOption).toBeVisible({ timeout: 3_000 });
await expect(webrtcOption).toContainText(
"under go2rtc → webrtc → candidates",
);
expect(probes).toBeGreaterThan(0);
});
test("desktop: JSMpeg is no longer offered in the technology selector", async ({ test("desktop: JSMpeg is no longer offered in the technology selector", async ({
frigateApp, frigateApp,
}) => { }) => {
+2 -1
View File
@@ -187,7 +187,8 @@
}, },
"unavailable": { "unavailable": {
"browser": "Your browser does not support WebRTC.", "browser": "Your browser does not support WebRTC.",
"not-configured": "WebRTC is not configured. Set go2rtc webrtc candidates or ice_servers.", "not-configured": "To enable, add this server's IP:8555 under go2rtc → webrtc → candidates.",
"notConfiguredOptional": "Optional: live view works without it. WebRTC is mainly needed for two-way talk.",
"unreachable": "WebRTC could not connect. Check that port 8555 is reachable and any STUN/TURN server is correct.", "unreachable": "WebRTC could not connect. Check that port 8555 is reachable and any STUN/TURN server is correct.",
"video-codec": "This stream's video codec is not supported by WebRTC in this browser.", "video-codec": "This stream's video codec is not supported by WebRTC in this browser.",
"audio-codec": "This stream's audio codec is not supported by WebRTC. Transcode to opus or G.711 with go2rtc.", "audio-codec": "This stream's audio codec is not supported by WebRTC. Transcode to opus or G.711 with go2rtc.",
@@ -7,7 +7,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LivePlayerMode, WebRTCUnavailableReason } from "@/types/live"; import { LivePlayerMode, WebRTCUnavailableReason } from "@/types/live";
import { LuX } from "react-icons/lu"; import { LuInfo, LuX } from "react-icons/lu";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type StreamTechnologySelectProps = { type StreamTechnologySelectProps = {
@@ -28,6 +28,7 @@ export default function StreamTechnologySelect({
const { t } = useTranslation(["views/live"]); const { t } = useTranslation(["views/live"]);
const isChecking = webRTCUnavailableReason === "checking"; const isChecking = webRTCUnavailableReason === "checking";
const isNotConfigured = webRTCUnavailableReason === "not-configured";
return ( return (
<Select <Select
@@ -68,7 +69,7 @@ export default function StreamTechnologySelect({
<span <span
className={cn( className={cn(
"mt-1 flex flex-row items-start gap-1.5 rounded-md border p-2", "mt-1 flex flex-row items-start gap-1.5 rounded-md border p-2",
isChecking isChecking || isNotConfigured
? "border-secondary-foreground/20 bg-secondary-foreground/10" ? "border-secondary-foreground/20 bg-secondary-foreground/10"
: "border-danger/20 bg-danger/10", : "border-danger/20 bg-danger/10",
)} )}
@@ -78,12 +79,21 @@ export default function StreamTechnologySelect({
className="mt-0.5 size-3.5 shrink-0" className="mt-0.5 size-3.5 shrink-0"
size={14} size={14}
/> />
) : isNotConfigured ? (
<LuInfo className="mt-0.5 size-3.5 shrink-0" />
) : ( ) : (
<LuX className="mt-0.5 size-3.5 shrink-0 text-danger" /> <LuX className="mt-0.5 size-3.5 shrink-0 text-danger" />
)} )}
<span className="text-xs text-secondary-foreground"> <span className="flex flex-col gap-1 text-xs text-secondary-foreground">
{t( <span>
`stream.technology.unavailable.${webRTCUnavailableReason}`, {t(
`stream.technology.unavailable.${webRTCUnavailableReason}`,
)}
</span>
{isNotConfigured && (
<span className="text-muted-foreground">
{t("stream.technology.unavailable.notConfiguredOptional")}
</span>
)} )}
</span> </span>
</span> </span>
+23 -5
View File
@@ -121,6 +121,10 @@ export function useWebRTCGloballyAvailable(
); );
}, [config]); }, [config]);
// go2rtc fills in default candidates at startup when none are set, so only
// the probe can tell whether WebRTC works
const defaultCandidates = config?.go2rtc?.webrtc?.candidates == null;
// the stream being viewed is the one most likely to be online // the stream being viewed is the one most likely to be online
const testStreams = useMemo(() => { const testStreams = useMemo(() => {
const streams = Object.keys(config?.go2rtc?.streams ?? {}); const streams = Object.keys(config?.go2rtc?.streams ?? {});
@@ -182,7 +186,11 @@ export function useWebRTCGloballyAvailable(
useEffect(() => { useEffect(() => {
const streams = testStreamsKey ? testStreamsKey.split("\n") : []; const streams = testStreamsKey ? testStreamsKey.split("\n") : [];
if (!browserOk || !configured || streams.length === 0) { if (
!browserOk ||
(!configured && !defaultCandidates) ||
streams.length === 0
) {
return; return;
} }
let cancelled = false; let cancelled = false;
@@ -197,23 +205,33 @@ export function useWebRTCGloballyAvailable(
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [browserOk, configured, testStreamsKey, iceServers, retryToken]); }, [
browserOk,
configured,
defaultCandidates,
testStreamsKey,
iceServers,
retryToken,
]);
const availability = useMemo<GlobalAvailability>(() => { const availability = useMemo<GlobalAvailability>(() => {
if (!browserOk) { if (!browserOk) {
return { globallyAvailable: false, globalReason: "browser" }; return { globallyAvailable: false, globalReason: "browser" };
} }
if (!configured) { if (!configured && !defaultCandidates) {
return { globallyAvailable: false, globalReason: "not-configured" }; return { globallyAvailable: false, globalReason: "not-configured" };
} }
if (probe.state === "pending") { if (probe.state === "pending") {
return { globallyAvailable: false, globalReason: "checking" }; return { globallyAvailable: false, globalReason: "checking" };
} }
if (probe.state === "fail") { if (probe.state === "fail") {
return { globallyAvailable: false, globalReason: "unreachable" }; return {
globallyAvailable: false,
globalReason: configured ? "unreachable" : "not-configured",
};
} }
return { globallyAvailable: true, globalReason: null }; return { globallyAvailable: true, globalReason: null };
}, [browserOk, configured, probe]); }, [browserOk, configured, defaultCandidates, probe]);
useEffect(() => { useEffect(() => {
const reason = availability.globalReason; const reason = availability.globalReason;