diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md
index 954787a08a..84aa337071 100644
--- a/docs/docs/configuration/live.md
+++ b/docs/docs/configuration/live.md
@@ -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.
- **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.
- **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.
diff --git a/web/e2e/specs/live-webrtc-availability.spec.ts b/web/e2e/specs/live-webrtc-availability.spec.ts
index b36944138d..49fc5668bd 100644
--- a/web/e2e/specs/live-webrtc-availability.spec.ts
+++ b/web/e2e/specs/live-webrtc-availability.spec.ts
@@ -110,6 +110,15 @@ test.describe("WebRTC availability gating @critical", () => {
// distinguishes enabled from disabled options.
const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ });
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 ({
@@ -214,6 +223,62 @@ test.describe("WebRTC availability gating @critical", () => {
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 ({
frigateApp,
}) => {
diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json
index c5b370cea0..c4f3f91b0b 100644
--- a/web/public/locales/en/views/live.json
+++ b/web/public/locales/en/views/live.json
@@ -187,7 +187,8 @@
},
"unavailable": {
"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.",
"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.",
diff --git a/web/src/components/player/StreamTechnologySelect.tsx b/web/src/components/player/StreamTechnologySelect.tsx
index fd08185510..36042c4b7e 100644
--- a/web/src/components/player/StreamTechnologySelect.tsx
+++ b/web/src/components/player/StreamTechnologySelect.tsx
@@ -7,7 +7,7 @@ import {
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { LivePlayerMode, WebRTCUnavailableReason } from "@/types/live";
-import { LuX } from "react-icons/lu";
+import { LuInfo, LuX } from "react-icons/lu";
import { useTranslation } from "react-i18next";
type StreamTechnologySelectProps = {
@@ -28,6 +28,7 @@ export default function StreamTechnologySelect({
const { t } = useTranslation(["views/live"]);
const isChecking = webRTCUnavailableReason === "checking";
+ const isNotConfigured = webRTCUnavailableReason === "not-configured";
return (
+ ) : isNotConfigured ? (
+
) : (
)}
-
- {t(
- `stream.technology.unavailable.${webRTCUnavailableReason}`,
+
+
+ {t(
+ `stream.technology.unavailable.${webRTCUnavailableReason}`,
+ )}
+
+ {isNotConfigured && (
+
+ {t("stream.technology.unavailable.notConfiguredOptional")}
+
)}
diff --git a/web/src/hooks/use-webrtc-availability.ts b/web/src/hooks/use-webrtc-availability.ts
index 4e641e115d..1d061e2df5 100644
--- a/web/src/hooks/use-webrtc-availability.ts
+++ b/web/src/hooks/use-webrtc-availability.ts
@@ -121,6 +121,10 @@ export function useWebRTCGloballyAvailable(
);
}, [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
const testStreams = useMemo(() => {
const streams = Object.keys(config?.go2rtc?.streams ?? {});
@@ -182,7 +186,11 @@ export function useWebRTCGloballyAvailable(
useEffect(() => {
const streams = testStreamsKey ? testStreamsKey.split("\n") : [];
- if (!browserOk || !configured || streams.length === 0) {
+ if (
+ !browserOk ||
+ (!configured && !defaultCandidates) ||
+ streams.length === 0
+ ) {
return;
}
let cancelled = false;
@@ -197,23 +205,33 @@ export function useWebRTCGloballyAvailable(
return () => {
cancelled = true;
};
- }, [browserOk, configured, testStreamsKey, iceServers, retryToken]);
+ }, [
+ browserOk,
+ configured,
+ defaultCandidates,
+ testStreamsKey,
+ iceServers,
+ retryToken,
+ ]);
const availability = useMemo(() => {
if (!browserOk) {
return { globallyAvailable: false, globalReason: "browser" };
}
- if (!configured) {
+ if (!configured && !defaultCandidates) {
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: false,
+ globalReason: configured ? "unreachable" : "not-configured",
+ };
}
return { globallyAvailable: true, globalReason: null };
- }, [browserOk, configured, probe]);
+ }, [browserOk, configured, defaultCandidates, probe]);
useEffect(() => {
const reason = availability.globalReason;