From 64d6366ac4be29cf9044a2a0e11ba29464ad9765 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:12:06 -0500 Subject: [PATCH] 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 --- docs/docs/configuration/live.md | 31 ++ docs/docs/frigate/network_requirements.md | 2 +- .../specs/live-webrtc-availability.spec.ts | 450 ++++++++++++++++++ .../settings/ui-settings-transfer.spec.ts | 91 +++- web/public/locales/en/components/player.json | 8 - web/public/locales/en/views/live.json | 37 +- web/src/components/menu/LiveContextMenu.tsx | 65 +-- web/src/components/player/JSMpegPlayer.tsx | 1 - web/src/components/player/LivePlayer.tsx | 11 +- web/src/components/player/MsePlayer.tsx | 11 - web/src/components/player/PlayerStats.tsx | 34 +- .../player/StreamTechnologySelect.tsx | 96 ++++ web/src/components/player/WebRTCPlayer.tsx | 207 +++++--- .../settings/CameraStreamingDialog.tsx | 174 +++++-- web/src/hooks/use-camera-live-mode.ts | 96 +++- web/src/hooks/use-user-persistence.ts | 7 +- web/src/hooks/use-webrtc-availability.ts | 233 +++++++++ web/src/types/frigateConfig.ts | 7 + web/src/types/live.ts | 11 +- web/src/utils/cameraUtil.ts | 60 ++- web/src/utils/uiSettingsTransfer.ts | 1 + web/src/utils/webrtcProbe.ts | 140 ++++++ web/src/utils/webrtcUtil.ts | 108 +++++ web/src/views/live/DraggableGridLayout.tsx | 19 +- web/src/views/live/LiveCameraView.tsx | 303 +++++++++--- web/src/views/live/LiveDashboardView.tsx | 20 +- 26 files changed, 1940 insertions(+), 283 deletions(-) create mode 100644 web/e2e/specs/live-webrtc-availability.spec.ts create mode 100644 web/src/components/player/StreamTechnologySelect.tsx create mode 100644 web/src/hooks/use-webrtc-availability.ts create mode 100644 web/src/utils/webrtcProbe.ts create mode 100644 web/src/utils/webrtcUtil.ts diff --git a/docs/docs/configuration/live.md b/docs/docs/configuration/live.md index 6f32c9ec3e..a636d1fe77 100644 --- a/docs/docs/configuration/live.md +++ b/docs/docs/configuration/live.md @@ -28,6 +28,24 @@ WebRTC may use an external STUN server for NAT traversal. MSE and HLS streaming ::: +### Selecting a streaming technology + +Frigate [defaults to MSE](#why-does-frigate-prefer-mse-over-webrtc-for-live-view) for restreamed cameras by design. To use WebRTC, select it explicitly from a camera's single-camera Live view settings (the settings menu in the camera's Live view header on desktop, or the settings drawer on mobile). Three related controls work together: + +- **Stream**: _what_ to play. This lists the [streams you've configured](#setting-streams-for-live-ui) (for example `Main Stream` and `Sub Stream`). +- **Force low-bandwidth mode**: a switch that always plays Frigate's built-in low-bandwidth feed (the stream assigned the `detect` role, using JSMpeg) instead of the selected stream. It works anywhere without go2rtc and is useful on slow or metered connections. While it is enabled, the stream and streaming technology selectors are disabled; your stream and technology choices are restored when you turn it off. +- **Streaming Technology**: _how_ to play the selected stream, listing **MSE** and **WebRTC**. It is only shown for a restreamed stream. + +- 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)). + - **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. + - **Unsupported browser**: the browser doesn't support WebRTC. + +When WebRTC isn't available, Frigate automatically uses MSE (or falls back to JSMpeg), so live view keeps working regardless of the selection. + ### Camera Settings Recommendations If you are using go2rtc, you should adjust the following settings in your camera's firmware for the best experience with Live view: @@ -157,6 +175,17 @@ WebRTC works by creating a TCP or UDP connection on port `8555`. However, it req - stun:8555 ``` +- The web UI uses the STUN and TURN servers in `ice_servers` and falls back to Google's public STUN server when none are set: + + ```yaml title="config.yml" + go2rtc: + webrtc: + ice_servers: + - urls: [turn:turn.example.com:3478] + username: frigate + credential: password + ``` + - For access through Tailscale, the Frigate system's Tailscale IP must be added as a WebRTC candidate. Tailscale IPs all start with `100.`, and are reserved within the `100.64.0.0/10` CIDR block. - Note that some browsers may not support H.265 (HEVC). You can check your browser's current version for H.265 compatibility [here](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#codecs-madness). @@ -206,6 +235,8 @@ For devices that support two way talk, Frigate can be configured to use the feat - Ensure you access Frigate via https (may require [opening port 8971](/frigate/installation/#ports)). - For the Home Assistant Frigate card, [follow the docs](http://card.camera/#/usage/2-way-audio) for the correct source. +The two-way talk control in the single-camera Live view is only enabled when WebRTC is available; if WebRTC isn't configured or can't connect, the control is shown disabled. + To use the Reolink Doorbell with two way talk, you should use the [recommended Reolink configuration](/configuration/camera_specific#reolink-cameras) As a starting point to check compatibility for your camera, view the list of cameras supported for two-way talk on the [go2rtc repository](https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#two-way-audio). For cameras in the category `ONVIF Profile T`, you can use the [ONVIF Conformant Products Database](https://www.onvif.org/conformant-products/)'s FeatureList to check for the presence of `AudioOutput`. A camera that supports `ONVIF Profile T` _usually_ supports this, but due to inconsistent support, a camera that explicitly lists this feature may still not work. If no entry for your camera exists on the database, it is recommended not to buy it or to consult with the manufacturer's support on the feature availability. diff --git a/docs/docs/frigate/network_requirements.md b/docs/docs/frigate/network_requirements.md index 2741e14504..f1b686a4ac 100644 --- a/docs/docs/frigate/network_requirements.md +++ b/docs/docs/frigate/network_requirements.md @@ -147,7 +147,7 @@ If an [MQTT broker](/integrations/mqtt) is configured, Frigate maintains a conne For [WebRTC live streaming](/configuration/live), Frigate uses STUN for NAT traversal: - **go2rtc** defaults to a local STUN listener (`stun:8555`), no internet required. -- **The web UI's WebRTC player** includes a fallback to Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet. +- **The web UI** uses the servers in `go2rtc.webrtc.ice_servers` for its WebRTC player and for the WebRTC connectivity check it runs when the Live view loads. If none are set, it uses Google's public STUN server (`stun:stun.l.google.com:19302`), which requires internet access from the browser. Set `ice_servers` to a STUN or TURN server on your network to avoid this. ## Home Assistant Supervisor diff --git a/web/e2e/specs/live-webrtc-availability.spec.ts b/web/e2e/specs/live-webrtc-availability.spec.ts new file mode 100644 index 0000000000..b36944138d --- /dev/null +++ b/web/e2e/specs/live-webrtc-availability.spec.ts @@ -0,0 +1,450 @@ +/** + * WebRTC streaming-technology availability gating. + * + * The connectivity probe needs a live go2rtc, so this covers the + * statically-determinable gate: with no webrtc candidates/ice_servers + * configured, the WebRTC option must be disabled in the stream-technology + * selector (label "Streaming Technology"). + */ +import type { Page } from "@playwright/test"; +import { test, expect } from "../fixtures/frigate-test"; +import { LivePage } from "../pages/live.page"; + +// the mocked profile is admin, so useUserPersistence keys are namespaced +const STREAMING_KEY = "streaming-settings:admin"; + +async function writeIdb(page: Page, entries: Record) { + await page.evaluate(async (data) => { + await new Promise((resolve, reject) => { + const request = indexedDB.open("keyval-store", 1); + request.onupgradeneeded = () => + request.result.createObjectStore("keyval"); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const tx = request.result.transaction("keyval", "readwrite"); + const store = tx.objectStore("keyval"); + Object.entries(data).forEach(([key, value]) => store.put(value, key)); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }; + }); + }, entries); +} + +async function readIdb(page: Page, key: string) { + return page.evaluate(async (target) => { + return new Promise((resolve, reject) => { + const request = indexedDB.open("keyval-store", 1); + request.onupgradeneeded = () => + request.result.createObjectStore("keyval"); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const tx = request.result.transaction("keyval", "readonly"); + const get = tx.objectStore("keyval").get(target); + get.onsuccess = () => resolve(get.result ?? null); + get.onerror = () => reject(get.error); + }; + }); + }, key); +} + +test.describe("WebRTC availability gating @critical", () => { + test("desktop: WebRTC option is disabled when no candidates or ice_servers", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop dropdown only"); + + await frigateApp.installDefaults({ + config: { + go2rtc: { + streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] }, + webrtc: { candidates: [], ice_servers: [] }, + }, + }, + }); + + // The single-camera view fetches go2rtc stream metadata once restreamed. + // The default mock returns {} which lacks `producers` and crashes the + // capability parser, so return a minimal valid metadata payload. + 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 }); + + // Open the desktop camera-settings dropdown (the FaCog gear is the last + // button-like trigger in the single-camera header). + const gearButtons = frigateApp.page.locator("button:has(svg)"); + await gearButtons.last().click(); + + const menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 3_000 }); + + // Open the "Streaming Technology" select. Anchor on its label, then click + // the combobox trigger that follows it within the same field container. + const technologyTrigger = menu + .locator('div:has(> label[for="streaming-mode"]) [role="combobox"]') + .first(); + await expect(technologyTrigger).toBeVisible({ timeout: 3_000 }); + await technologyTrigger.click(); + + // The Radix select content is portaled to the document body; the WebRTC + // option must be present and disabled (aria-disabled="true"). + const webrtcOption = frigateApp.page.getByRole("option", { + name: /WebRTC/, + }); + await expect(webrtcOption).toBeVisible({ timeout: 3_000 }); + await expect(webrtcOption).toHaveAttribute("aria-disabled", "true"); + + // Sanity check: a non-gated option (MSE) is enabled, proving the locator + // distinguishes enabled from disabled options. + const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ }); + await expect(mseOption).not.toHaveAttribute("aria-disabled", "true"); + }); + + test("the unavailable reason is logged to the console once", async ({ + frigateApp, + }) => { + // Availability is consumed by several components at once, so the emitter + // dedupes; the count asserts that dedupe, not just the message. + const warnings: string[] = []; + frigateApp.page.on("console", (msg) => { + if (msg.type() === "warning" && msg.text().includes("WebRTC unavailable")) + warnings.push(msg.text()); + }); + + await frigateApp.installDefaults({ + config: { + go2rtc: { + streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] }, + webrtc: { candidates: [], ice_servers: [] }, + }, + }, + }); + + await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) => + route.fulfill({ + json: { + producers: [{ medias: ["video, recvonly, H264"] }], + consumers: [], + }, + }), + ); + + await frigateApp.goto("/#front_door"); + + await expect + .poll(() => warnings.length, { timeout: 10_000 }) + .toBeGreaterThan(0); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("WebRTC unavailable 'not-configured'"); + expect(warnings[0]).toContain("go2rtc.webrtc"); + expect(warnings[0]).toContain("docs.frigate.video"); + }); + + test("desktop: the WebRTC option reports the pending connectivity check", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop dropdown only"); + + // Hold the signaling socket open so the probe stays pending. Left alone it + // fails fast against the preview server and resolves to unreachable, which + // is the state the first test already covers. + await frigateApp.page.routeWebSocket("**/live/webrtc/api/ws**", () => { + // never answer the offer + }); + + // Candidates configured, so the gate reaches the probe rather than + // stopping at not-configured. + await frigateApp.installDefaults({ + config: { + go2rtc: { + streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] }, + webrtc: { candidates: ["192.168.1.10:8555"], ice_servers: [] }, + }, + }, + }); + + 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 }); + + const gearButtons = frigateApp.page.locator("button:has(svg)"); + await gearButtons.last().click(); + + const menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 3_000 }); + + const technologyTrigger = menu + .locator('div:has(> label[for="streaming-mode"]) [role="combobox"]') + .first(); + await expect(technologyTrigger).toBeVisible({ timeout: 3_000 }); + await technologyTrigger.click(); + + // Unselectable while the probe runs, but with the reason stated rather + // than a bare greyed-out row. + const webrtcOption = frigateApp.page.getByRole("option", { + name: /WebRTC/, + }); + await expect(webrtcOption).toBeVisible({ timeout: 3_000 }); + await expect(webrtcOption).toHaveAttribute("aria-disabled", "true"); + await expect(webrtcOption).toContainText(/Checking WebRTC availability/i); + }); + + test("desktop: JSMpeg is no longer offered in the technology selector", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop dropdown only"); + + await frigateApp.installDefaults({ + config: { + go2rtc: { + streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] }, + webrtc: { candidates: [], ice_servers: [] }, + }, + }, + }); + + 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 }); + + const gearButtons = frigateApp.page.locator("button:has(svg)"); + await gearButtons.last().click(); + + const menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 3_000 }); + + // Technology selector offers MSE/WebRTC but NOT JSMpeg (replaced by the + // "Force low-bandwidth mode" switch). + const technologyTrigger = menu + .locator('div:has(> label[for="streaming-mode"]) [role="combobox"]') + .first(); + await expect(technologyTrigger).toBeVisible({ timeout: 3_000 }); + await technologyTrigger.click(); + await expect( + frigateApp.page.getByRole("option", { name: /MSE/ }), + ).toBeVisible({ timeout: 3_000 }); + await expect( + frigateApp.page.getByRole("option", { name: /JSMpeg/ }), + ).toHaveCount(0); + }); + + test("desktop: force low-bandwidth switch disables the technology and stream selectors", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop dropdown only"); + + await frigateApp.installDefaults({ + config: { + go2rtc: { + streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] }, + webrtc: { candidates: [], ice_servers: [] }, + }, + }, + }); + + 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 }); + + const gearButtons = frigateApp.page.locator("button:has(svg)"); + await gearButtons.last().click(); + + const menu = frigateApp.page + .locator('[role="menu"], [data-radix-menu-content]') + .first(); + await expect(menu).toBeVisible({ timeout: 3_000 }); + + // Both selectors are present and enabled to begin with (the switch is off). + await expect(menu.locator('label[for="streaming-mode"]')).toHaveCount(1); + const technologyTrigger = menu + .locator('div:has(> label[for="streaming-mode"]) [role="combobox"]') + .first(); + const streamTrigger = menu + .locator('div:has(> label[for="streaming-method"]) [role="combobox"]') + .first(); + await expect(technologyTrigger).toBeVisible({ timeout: 3_000 }); + await expect(technologyTrigger).toBeEnabled(); + await expect(streamTrigger).toBeVisible({ timeout: 3_000 }); + await expect(streamTrigger).toBeEnabled(); + + // Enabling the switch keeps both selectors visible but disables them (the + // low-bandwidth feed ignores the chosen stream and technology). + const lowBandwidthSwitch = menu.getByRole("switch", { + name: "Force low-bandwidth mode", + }); + await expect(lowBandwidthSwitch).toBeVisible({ timeout: 3_000 }); + await lowBandwidthSwitch.click(); + + await expect(menu.locator('label[for="streaming-mode"]')).toHaveCount(1); + await expect(technologyTrigger).toBeDisabled(); + await expect(streamTrigger).toBeDisabled(); + + // Toggling it back off re-enables both. + await lowBandwidthSwitch.click(); + await expect(technologyTrigger).toBeEnabled(); + await expect(streamTrigger).toBeEnabled(); + }); + + test("desktop: saving group streaming settings keeps an unavailable WebRTC choice", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop context menu only"); + + await frigateApp.installDefaults({ + config: { + go2rtc: { + streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] }, + webrtc: { candidates: [], ice_servers: [] }, + }, + }, + }); + + await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) => + route.fulfill({ + json: { + producers: [{ medias: ["video, recvonly, H264"] }], + consumers: [], + }, + }), + ); + + const saved = { + streamName: "front_door", + streamType: "smart", + playerMode: "webrtc", + compatibilityMode: false, + playAudio: false, + volume: 1, + }; + + await frigateApp.goto("/"); + await writeIdb(frigateApp.page, { + [STREAMING_KEY]: { outdoor: { front_door: saved } }, + }); + await frigateApp.goto("/?group=outdoor"); + + // With no candidates the dialog resolves WebRTC to MSE for display, but + // saving must not persist that fallback over the user's choice. + const live = new LivePage(frigateApp.page, true); + const menu = await live.openContextMenuOn("front_door"); + await expect(menu).toBeVisible({ timeout: 5_000 }); + await menu.getByText("Streaming Settings").click(); + + const dialog = frigateApp.page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Save" }).click(); + await expect(dialog).toBeHidden(); + + await expect + .poll(() => readIdb(frigateApp.page, STREAMING_KEY)) + .toMatchObject({ outdoor: { front_door: { playerMode: "webrtc" } } }); + }); +}); + +test.describe("WebRTC availability gating @critical @mobile", () => { + test("mobile: WebRTC option is disabled when no candidates or ice_servers", async ({ + frigateApp, + }) => { + test.skip(!frigateApp.isMobile, "Mobile drawer only"); + + await frigateApp.installDefaults({ + config: { + go2rtc: { + streams: { front_door: ["rtsp://127.0.0.1:8554/front_door"] }, + webrtc: { candidates: [], ice_servers: [] }, + }, + }, + }); + + await frigateApp.page.route("**/api/go2rtc/streams/front_door**", (route) => + route.fulfill({ + json: { + producers: [{ medias: ["video, recvonly, H264"] }], + consumers: [], + }, + }), + ); + + await frigateApp.goto("/#front_door"); + + // Open the mobile camera-settings drawer. The camera controls render a + // second dialog-popup trigger (the FaCog settings toggle) after the global + // header menu; wait for both to exist, then click the last one. + const dialogTriggers = frigateApp.page.locator( + 'button[aria-haspopup="dialog"]', + ); + await expect(dialogTriggers).toHaveCount(2, { timeout: 10_000 }); + const settingsTrigger = dialogTriggers.last(); + await settingsTrigger.scrollIntoViewIfNeeded(); + await settingsTrigger.click(); + + // The drawer renders a "Streaming Technology" label above its select. + // Anchor on that label, then open the combobox in the same field block. + await expect( + frigateApp.page.getByText("Streaming Technology", { exact: true }), + ).toBeVisible({ timeout: 3_000 }); + const technologyTrigger = frigateApp.page + .locator( + 'div:has(> div:text-is("Streaming Technology")) [role="combobox"]', + ) + .first(); + await expect(technologyTrigger).toBeVisible({ timeout: 3_000 }); + await technologyTrigger.click(); + + const webrtcOption = frigateApp.page.getByRole("option", { + name: /WebRTC/, + }); + await expect(webrtcOption).toBeVisible({ timeout: 3_000 }); + await expect(webrtcOption).toHaveAttribute("aria-disabled", "true"); + + const mseOption = frigateApp.page.getByRole("option", { name: /MSE/ }); + await expect(mseOption).not.toHaveAttribute("aria-disabled", "true"); + }); +}); diff --git a/web/e2e/specs/settings/ui-settings-transfer.spec.ts b/web/e2e/specs/settings/ui-settings-transfer.spec.ts index 6b3f5a9b3c..71c553f496 100644 --- a/web/e2e/specs/settings/ui-settings-transfer.spec.ts +++ b/web/e2e/specs/settings/ui-settings-transfer.spec.ts @@ -18,15 +18,20 @@ const OUTDOOR_LAYOUT = [ { i: "backyard", x: 6, y: 0, w: 6, h: 4 }, ]; +// a camera as an export from before streaming technology selection carries +// it, so tests can assert both what a pre-feature file writes and what the +// technology adds on top +const FRONT_DOOR_WITHOUT_TECHNOLOGY = { + streamName: "front_door", + streamType: "smart", + compatibilityMode: false, + playAudio: false, + volume: 1, +}; + const STREAMING_SETTINGS = { outdoor: { - front_door: { - streamName: "front_door", - streamType: "smart", - compatibilityMode: false, - playAudio: false, - volume: 1, - }, + front_door: { ...FRONT_DOOR_WITHOUT_TECHNOLOGY, playerMode: "webrtc" }, }, }; @@ -340,6 +345,7 @@ test.describe("UI settings import/export @medium", () => { garage: { streamName: "garage", streamType: "continuous", + playerMode: "jsmpeg", compatibilityMode: true, playAudio: true, volume: 0.5, @@ -375,6 +381,77 @@ test.describe("UI settings import/export @medium", () => { }, }); }); + + test("drops only the streaming technology when its value is unrecognized", async ({ + frigateApp, + }) => { + // a hand-edited file, or an export from a future Frigate that added a + // technology this build does not know: the camera's other settings still + // import rather than the whole file failing validation + await frigateApp.goto("/settings?page=uiSettings"); + + await chooseImportFile( + frigateApp.page, + importPayload({ + sections: { + layouts: {}, + streaming: { + outdoor: { + front_door: { + ...FRONT_DOOR_WITHOUT_TECHNOLOGY, + playerMode: "quantum", + }, + }, + }, + preferences: {}, + }, + }), + ); + + await expect( + frigateApp.page.getByText("Streaming settings (1 camera)"), + ).toBeVisible(); + + await confirmImport(frigateApp.page); + + expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({ + outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY }, + }); + }); + + test("clears a stored streaming technology when the file predates it", async ({ + frigateApp, + }) => { + // import replaces whole camera objects, as it already does for streamName + // and volume, so a pre-feature export resets the technology rather than + // leaving the local choice in place + await frigateApp.goto("/settings?page=uiSettings"); + + await writeIdb(frigateApp.page, { [STREAMING_KEY]: STREAMING_SETTINGS }); + await chooseImportFile( + frigateApp.page, + importPayload({ + sections: { + layouts: {}, + streaming: { + outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY }, + }, + preferences: {}, + }, + }), + ); + + await expect( + frigateApp.page.getByText("Streaming settings (1 camera)"), + ).toBeVisible(); + + await confirmImport(frigateApp.page); + + expect(await readIdb(frigateApp.page, STREAMING_KEY)).toEqual({ + outdoor: { front_door: FRONT_DOOR_WITHOUT_TECHNOLOGY }, + }); + }); + test("rejects a file that is not valid JSON", async ({ frigateApp }) => { await frigateApp.goto("/settings?page=uiSettings"); diff --git a/web/public/locales/en/components/player.json b/web/public/locales/en/components/player.json index de67ea156b..57623394b3 100644 --- a/web/public/locales/en/components/player.json +++ b/web/public/locales/en/components/player.json @@ -36,14 +36,6 @@ "title": "Bandwidth:", "short": "Bandwidth" }, - "latency": { - "title": "Latency:", - "value": "{{seconds}} seconds", - "short": { - "title": "Latency", - "value": "{{seconds}} sec" - } - }, "totalFrames": "Total Frames:", "droppedFrames": { "title": "Dropped Frames:", diff --git a/web/public/locales/en/views/live.json b/web/public/locales/en/views/live.json index f2ea2721f1..917d3993e9 100644 --- a/web/public/locales/en/views/live.json +++ b/web/public/locales/en/views/live.json @@ -6,7 +6,12 @@ "lowBandwidthMode": "Low-bandwidth Mode", "twoWayTalk": { "enable": "Enable Two Way Talk", - "disable": "Disable Two Way Talk" + "disable": "Disable Two Way Talk", + "requiresWebRTC": "Two-way talk requires WebRTC, which is unavailable", + "error": { + "microphone": "Two-way talk could not access your microphone", + "refused": "Two-way talk failed to start. See the browser console for details." + } }, "cameraAudio": { "enable": "Enable Camera Audio", @@ -135,7 +140,8 @@ "unavailable": "Audio is not available for this stream" }, "debug": { - "picker": "Stream selection unavailable in debug mode. Debug view always uses the stream assigned the detect role." + "picker": "Stream selection unavailable in debug mode. Debug view always uses the stream assigned the detect role.", + "technology": "Stream technology selection is unavailable in debug mode." }, "twoWayTalk": { "tips": "Your device must support the feature and WebRTC must be configured for two-way talk.", @@ -144,11 +150,36 @@ }, "lowBandwidth": { "tips": "Live view is in low-bandwidth mode due to buffering or stream errors.", - "resetStream": "Reset stream" + "resetStream": "Reset stream", + "force": { + "label": "Force low-bandwidth mode", + "desc": "Always play Frigate's built-in low-bandwidth feed instead of the selected stream. Works on any connection, but has lower quality and no audio." + } }, "playInBackground": { "label": "Play in background", "tips": "Enable this option to continue streaming when the player is hidden." + }, + "mode": "Streaming Technology", + "technology": { + "description": "Choose your preferred streaming technology. Frigate may still fall back to low bandwidth mode on playback or network errors.", + "name": { + "mse": "MSE", + "webrtc": "WebRTC", + "jsmpeg": "JSMpeg" + }, + "tips": { + "mse": "Recommended default, broad compatibility and smooth playback", + "webrtc": "Needs extra setup and isn't supported on every device" + }, + "unavailable": { + "browser": "Your browser does not support WebRTC.", + "not-configured": "WebRTC is not configured. Set go2rtc webrtc candidates or ice_servers.", + "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.", + "checking": "Checking WebRTC availability…" + } } }, "cameraSettings": { diff --git a/web/src/components/menu/LiveContextMenu.tsx b/web/src/components/menu/LiveContextMenu.tsx index a4f2e84982..c1ae9c0eff 100644 --- a/web/src/components/menu/LiveContextMenu.tsx +++ b/web/src/components/menu/LiveContextMenu.tsx @@ -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({
- {preferredLiveMode == "jsmpeg" && isRestreamed && ( -
- -

{t("lowBandwidthMode")}

-
- )} + {preferredLiveMode == "jsmpeg" && + isRestreamed && + !isForcedLowBandwidth && ( +
+ +

{t("lowBandwidthMode")}

+
+ )} {preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudio && ( <> @@ -374,21 +379,23 @@ export default function LiveContextMenu({ )} - {preferredLiveMode == "jsmpeg" && isRestreamed && ( - <> - - -
-
- {t("button.reset", { ns: "common" })} + {preferredLiveMode == "jsmpeg" && + isRestreamed && + !isForcedLowBandwidth && ( + <> + + +
+
+ {t("button.reset", { ns: "common" })} +
-
- - - )} + + + )} {notificationsEnabledInConfig && isEnabled && ( <> @@ -548,14 +555,16 @@ export default function LiveContextMenu({ - + {showSettings && ( + + )}
); diff --git a/web/src/components/player/JSMpegPlayer.tsx b/web/src/components/player/JSMpegPlayer.tsx index 7258f20318..95c130053d 100644 --- a/web/src/components/player/JSMpegPlayer.tsx +++ b/web/src/components/player/JSMpegPlayer.tsx @@ -174,7 +174,6 @@ export default function JSMpegPlayer({ streamType: "jsmpeg", bandwidth: Math.round(bitrate), totalFrames: frameCount, - latency: undefined, droppedFrames: undefined, decodedFrames: undefined, droppedFrameRate: undefined, diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index e117a33bf8..69da66e016 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -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>; 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({ 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 && } + !liveReady && ( +
+ +
+ )} {((showStillWithoutActivity && !liveReady) || liveReady) && objects.length > 0 && ( diff --git a/web/src/components/player/MsePlayer.tsx b/web/src/components/player/MsePlayer.tsx index 3bb143b5c0..1e87fd7214 100644 --- a/web/src/components/player/MsePlayer.tsx +++ b/web/src/components/player/MsePlayer.tsx @@ -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, diff --git a/web/src/components/player/PlayerStats.tsx b/web/src/components/player/PlayerStats.tsx index 6d7e19f5ec..267bf5310d 100644 --- a/web/src/components/player/PlayerStats.tsx +++ b/web/src/components/player/PlayerStats.tsx @@ -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 = ( <>

{t("stats.streamType.title")}{" "} - {stats.streamType} + {streamTypeLabel}

{t("stats.bandwidth.title")}{" "} {stats.bandwidth.toFixed(2)} kBps

- {stats.latency != undefined && ( -

- {t("stats.latency.title")}{" "} - 2 ? "text-danger" : ""}`} - > - {t("stats.latency.value", { seconds: stats.latency.toFixed(2) })} - -

- )}

{t("stats.totalFrames")}{" "} {stats.totalFrames} @@ -62,26 +56,12 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {

{t("stats.streamType.short")} - {stats.streamType} + {streamTypeLabel}
{t("stats.bandwidth.short")}{" "} {stats.bandwidth.toFixed(2)} kBps
- {stats.latency != undefined && ( -
- - {t("stats.latency.short.title")} - - = 2 ? "text-danger" : ""}`} - > - {t("stats.latency.short.value", { - seconds: stats.latency.toFixed(2), - })} - -
- )} {stats.droppedFrames != undefined && (
diff --git a/web/src/components/player/StreamTechnologySelect.tsx b/web/src/components/player/StreamTechnologySelect.tsx new file mode 100644 index 0000000000..fd08185510 --- /dev/null +++ b/web/src/components/player/StreamTechnologySelect.tsx @@ -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 ( + + ); +} diff --git a/web/src/components/player/WebRTCPlayer.tsx b/web/src/components/player/WebRTCPlayer.tsx index 0f5da312eb..cbdab7331a 100644 --- a/web/src/components/player/WebRTCPlayer.tsx +++ b/web/src/components/player/WebRTCPlayer.tsx @@ -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("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(undefined); const wsRef = useRef(null); const videoRef = useRef(null); + // Separate sendonly-audio connection for two-way talk: go2rtc only wires the + // backchannel from a connection's initial offer. + const micPcRef = useRef(undefined); + const micWsRef = useRef(null); const [bufferTimeout, setBufferTimeout] = useState(); const videoLoadTimeoutRef = useRef(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) => { 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, diff --git a/web/src/components/settings/CameraStreamingDialog.tsx b/web/src/components/settings/CameraStreamingDialog.tsx index 4cabd2d860..b5a5a2b529 100644 --- a/web/src/components/settings/CameraStreamingDialog.tsx +++ b/web/src/components/settings/CameraStreamingDialog.tsx @@ -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("config"); @@ -68,6 +75,8 @@ export function CameraStreamingDialog({ Object.entries(config?.cameras[camera]?.live?.streams || {})[0]?.[1] || "", ); const [streamType, setStreamType] = useState("smart"); + const [playerMode, setPlayerMode] = useState("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( + 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( + () => (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({ - -
- {supportsAudioOutput ? ( - <> - -
{t("group.camera.setting.audioIsAvailable")}
- - ) : ( - <> - -
{t("group.camera.setting.audioIsUnavailable")}
- - -
- - - {t("button.info", { ns: "common" })} - -
-
- - {t("group.camera.setting.audio.tips.title")} -
- - {t("readTheDocumentation", { ns: "common" })} - - -
-
-
- - )} -
+ {!forceLowBandwidth && ( +
+ {supportsAudioOutput ? ( + <> + +
{t("group.camera.setting.audioIsAvailable")}
+ + ) : ( + <> + +
+ {t("group.camera.setting.audioIsUnavailable")} +
+ + +
+ + + {t("button.info", { ns: "common" })} + +
+
+ + {t("group.camera.setting.audio.tips.title")} +
+ + {t("readTheDocumentation", { ns: "common" })} + + +
+
+
+ + )} +
+ )}
)} + {isRestreamed && + Object.entries(config?.cameras[camera].live.streams).length > 0 && ( +
+ + +

+ {t("stream.technology.description", { ns: "views/live" })} +

+
+ )} + {isRestreamed && + Object.entries(config?.cameras[camera].live.streams).length > 0 && ( +
+
+ + +
+

+ {t("stream.lowBandwidth.force.desc", { ns: "views/live" })} +

+
+ )}
@@ -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>; 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(null); @@ -1235,17 +1354,13 @@ function FrigateCameraFeatures({