mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-24 18:26:51 +03:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
await page.evaluate(async (data) => {
|
||||
await new Promise<void>((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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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_";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user