Compare commits

...
Author SHA1 Message Date
dependabot[bot]andGitHub 09ce1ac354 Bump zod from 3.25.76 to 4.6.5 in /web
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.6.5.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.6.5)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.6.5
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-17 11:33:16 +00:00
Josh HawkinsandGitHub 10a0d5ea37 Improve UI zone operations (#24376)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* improve zone renaming

Zone rename now saves as one JSON body via config/set, moving required_zones and profile overrides instead of leaving stale references

* fixes
2026-09-16 12:07:05 -06:00
Josh HawkinsandGitHub 64d6366ac4 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
2026-09-16 09:12:06 -05:00
Josh HawkinsandGitHub 79ca18d439 fix build warnings from tailwind, fonts, and fast refresh (#24359)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
fix duplicated styles, move fonts to src/assets/fonts for vite to bundle (nginx already rewrites correctly), and fix fast refresh for PreviewController, auth context/provider, and statusbar context
2026-09-15 15:33:09 -06:00
Josh HawkinsandGitHub cabdffea20 Update more web deps and reformat with updated prettier (#24358)
* update date-fns, i18next, react-dropzone, react-markdown and @types/node

react-i18next 17 peers `i18next >= 26.2.0`, so the two move together. react-day-picker already depends on date-fns 4, so date-fns now dedupes to a single copy. react-dropzone 20 declares `node >=22` in `engines`, but npm only warns on Node 20 and nothing in the build needs Node 22.

* update js-yaml, @hookform/resolvers and prettier

js-yaml 5 has no default export, so `DictAsYamlField` imports `dump`, `load` and `YAMLException` by name. @hookform/resolvers 5 types `zodResolver` with the schema's input and output types separately, and fields with defaults are optional on input, so the zone and classification model forms pass both types to `useForm`. zod's range now starts at 3.25, which resolvers 5 requires.

* format with prettier 3.9
2026-09-15 15:18:49 -06:00
Josh HawkinsandGitHub ba41c90c07 Remove unused deps (#24355)
* remove unused web test deps

Nothing runs vitest. The CI step that called `npm run test` is commented out, `web/__test__/` was deleted in https://github.com/blakeblackshear/frigate/pull/8983 so `setupFiles` points at a missing file, and there are no unit tests, so `npx vitest run` only picks up the Playwright specs and fails. jsdom, `@testing-library/jest-dom`, msw and fake-indexeddb were only there for vitest.

* update contributing docs

* remove unused deps
2026-09-15 14:02:02 -06:00
133 changed files with 3361 additions and 2846 deletions
-3
View File
@@ -46,9 +46,6 @@ jobs:
- name: Build web
run: npm run build
working-directory: ./web
# - name: Test
# run: npm run test
# working-directory: ./web
web_e2e:
name: Web - E2E Tests
@@ -343,13 +343,6 @@ http {
add_header Cache-Control "public";
}
location /fonts/ {
access_log off;
expires 1y;
include security_headers.conf;
add_header Cache-Control "public";
}
location /locales/ {
access_log off;
include security_headers.conf;
+31
View File
@@ -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.
+12 -3
View File
@@ -204,11 +204,20 @@ Light guidelines and advice:
npm run lint
```
- Add to unit tests and ensure they pass. As much as possible, you should strive to _increase_ test coverage whenever making changes. This will help ensure features do not accidentally become broken in the future.
- If you run into error messages like "TypeError: Cannot read properties of undefined (reading 'context')" when running tests, this may be due to these issues (https://github.com/vitest-dev/vitest/issues/1910, https://github.com/vitest-dev/vitest/issues/1652) in vitest, but I haven't been able to resolve them.
- Ensure the backend [unit tests](#unit-tests) pass. Your PR cannot be merged unless tests pass.
```shell
python3 -u -m unittest
```
- Ensure the end-to-end tests pass. They run in Playwright against a production build with mocked API data, so they don't need a running Frigate instance. Add or update tests in `web/e2e/specs/` when you change UI behavior.
```console
npm run test
# First-time setup
npx playwright install chromium
# Build the app and run all tests
npm run e2e:build && npm run e2e
```
- Test in different browsers. Firefox, Chrome, and Safari all have different quirks that make them unique targets to interact with.
+1 -1
View File
@@ -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,193 @@
"""Tests for renaming and deleting a zone through config_set's JSON body."""
import os
import tempfile
from unittest.mock import MagicMock, Mock, patch
import ruamel.yaml
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestConfigSetZones(BaseTestHttp):
def setUp(self):
super().setUp(models=[Event, Recordings, ReviewSegment])
self.minimal_config = {
"mqtt": {"host": "mqtt"},
"profiles": {"armed": {"friendly_name": "Armed"}},
"snapshots": {"required_zones": ["driveway"]},
"cameras": {
"front": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {"height": 1080, "width": 1920, "fps": 5},
"zones": {
"driveway": {
"coordinates": "0,0,1,0,1,1",
"inertia": 5,
"filters": {"person": {"min_area": 5000}},
},
"porch": {"coordinates": "0,0,0.5,0,0.5,0.5"},
},
"review": {
"alerts": {
"labels": ["person"],
"required_zones": ["driveway"],
},
},
"mqtt": {"required_zones": ["driveway", "porch"]},
"profiles": {
"armed": {
"zones": {
"driveway": {
"coordinates": "0,0,1,0,1,1",
"objects": ["car"],
},
},
},
},
},
},
}
yaml = ruamel.yaml.YAML()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".yml", delete=False
) as config_file:
yaml.dump(self.minimal_config, config_file)
self.config_path = config_file.name
self.addCleanup(os.unlink, self.config_path)
def _create_app(self):
publisher = Mock(spec=CameraConfigUpdatePublisher)
publisher.publisher = MagicMock()
app = create_fastapi_app(
FrigateConfig(**self.minimal_config),
self.db,
None,
None,
None,
None,
None,
None,
publisher,
None,
enforce_default_admin=False,
)
async def mock_get_current_user(request: Request):
return {
"username": request.headers.get("remote-user"),
"role": request.headers.get("remote-role"),
}
async def mock_get_allowed_cameras_for_filter(request: Request):
return ["front"]
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
return app
def _put(self, camera_data: dict):
with patch("frigate.api.app.find_config_file", return_value=self.config_path):
with AuthTestClient(self._create_app()) as client:
return client.put(
"/config/set",
json={
"config_data": {"cameras": {"front": camera_data}},
"requires_restart": 0,
"update_topic": "config/cameras/front/zones",
},
)
def _front(self) -> dict:
with open(self.config_path) as f:
return ruamel.yaml.YAML().load(f)["cameras"]["front"]
def test_rename_moves_zone_references_in_one_request(self):
"""The new base zone exists when validation checks the moved override."""
resp = self._put(
{
"zones": {
"driveway": None,
"front_drive": {
"coordinates": "0,0,1,0,1,1",
"enabled": True,
# as /api/config returns them, with defaults filled in
"filters": {
"person": {
"min_area": 5000,
"max_area": 24000000,
"min_ratio": 0.0,
"max_ratio": 24000000.0,
"threshold": 0.7,
"min_score": 0.5,
"mask": {},
}
},
"inertia": 5,
},
},
"review": {"alerts": {"required_zones": ["front_drive"]}},
# inherited from the global snapshots config, so no camera key
"snapshots": {"required_zones": ["front_drive"]},
"mqtt": {"required_zones": ["front_drive", "porch"]},
"profiles": {
"armed": {
"zones": {
"driveway": None,
"front_drive": {
"coordinates": "0,0,1,0,1,1",
"objects": ["car"],
},
},
},
},
}
)
self.assertEqual(resp.status_code, 200, resp.json())
front = self._front()
self.assertEqual(list(front["zones"]), ["porch", "front_drive"])
self.assertEqual(front["zones"]["front_drive"]["inertia"], 5)
self.assertEqual(
front["zones"]["front_drive"]["filters"]["person"]["min_area"], 5000
)
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
self.assertEqual(front["review"]["alerts"]["required_zones"], ["front_drive"])
self.assertEqual(front["snapshots"]["required_zones"], ["front_drive"])
self.assertEqual(front["mqtt"]["required_zones"], ["front_drive", "porch"])
self.assertEqual(
dict(front["profiles"]["armed"]["zones"]),
{"front_drive": {"coordinates": "0,0,1,0,1,1", "objects": ["car"]}},
)
def test_delete_empties_lists_without_dropping_their_section(self):
resp = self._put(
{
"zones": {"driveway": None},
"review": {"alerts": {"required_zones": []}},
"snapshots": {"required_zones": []},
"mqtt": {"required_zones": ["porch"]},
"profiles": {"armed": {"zones": {"driveway": None}}},
}
)
self.assertEqual(resp.status_code, 200, resp.json())
front = self._front()
self.assertEqual(list(front["zones"]), ["porch"])
self.assertEqual(front["review"]["alerts"]["labels"], ["person"])
self.assertEqual(front["review"]["alerts"]["required_zones"], [])
self.assertEqual(front["mqtt"]["required_zones"], ["porch"])
self.assertNotIn("driveway", front["profiles"]["armed"]["zones"] or {})
@@ -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");
+251
View File
@@ -0,0 +1,251 @@
/**
* Zone rename and delete tests -- MEDIUM tier.
*
* A zone's name also lives in required_zones lists and profile overrides.
* These tests pin that renaming or deleting a zone sends one config/set JSON
* body that moves or drops every reference, with nothing in the query string,
* and flags a restart. Editing the name alone must not rename the zone.
*/
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const SETTINGS_URL = "/settings?page=masksAndZones&camera=front_door";
const COORDINATES = "0.1,0.1,0.5,0.1,0.5,0.5,0.1,0.5";
// /api/config returns zone filters with defaults filled in
const FILTERS = {
person: {
min_area: 5000,
max_area: 24000000,
min_ratio: 0,
max_ratio: 24000000,
threshold: 0.7,
min_score: 0.5,
mask: {},
},
};
type ConfigSetRequest = { url: string; body: Record<string, unknown> };
async function installRoutes(page: Page) {
const config = configFactory({
profiles: { armed: { friendly_name: "Armed" } },
cameras: {
front_door: {
zones: {
driveway: {
coordinates: COORDINATES,
enabled: true,
inertia: 3,
loitering_time: 0,
objects: [],
filters: FILTERS,
color: [128, 128, 0],
},
},
review: { alerts: { required_zones: ["driveway"] } },
snapshots: { required_zones: ["driveway"] },
mqtt: { required_zones: ["driveway"] },
profiles: {
armed: {
zones: { driveway: { coordinates: COORDINATES, inertia: 6 } },
review: { alerts: { required_zones: ["driveway"] } },
},
},
},
},
});
const requests: ConfigSetRequest[] = [];
await page.route("**/api/config", (route) => route.fulfill({ json: config }));
await page.route("**/api/config/set**", async (route) => {
requests.push({
url: route.request().url(),
body: route.request().postDataJSON(),
});
await route.fulfill({ json: { success: true } });
});
return requests;
}
async function openZoneAction(
page: Page,
isMobile: boolean,
action: "Edit" | "Delete",
) {
const row = page.locator("[data-index]", { hasText: "Driveway" });
if (isMobile) {
await row.locator("button[aria-haspopup='menu']").click();
await page.getByRole("menuitem", { name: action }).click();
return;
}
// Desktop shows the actions on hover
await row.hover();
await row.getByLabel(action, { exact: true }).click();
}
test.describe("zone rename and delete @medium @mobile", () => {
test("editing the name of a referenced zone keeps its id", async ({
frigateApp,
}) => {
const requests = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
await frigateApp.page
.getByRole("textbox", { name: "Name", exact: true })
.fill("Main Driveway");
await expect(
frigateApp.page.getByRole("textbox", { name: "ID", exact: true }),
).toHaveValue("driveway");
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => requests.length).toBe(1);
expect(new URL(requests[0].url).search).toBe("");
expect(requests[0].body).toEqual({
requires_restart: 0,
update_topic: "config/cameras/front_door/zones",
config_data: {
cameras: {
front_door: {
zones: {
driveway: {
coordinates: COORDINATES,
enabled: true,
inertia: 3,
loitering_time: 0,
friendly_name: "Main Driveway",
},
},
},
},
},
});
});
test("turning speed estimation on and off sends no distances delete", async ({
frigateApp,
}) => {
// The zone has no distances in the YAML, and config/set fails to delete
// a missing key
const requests = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
const speedEstimation = frigateApp.page
.getByText("Speed Estimation", { exact: true })
.locator("..")
.getByRole("switch");
await speedEstimation.click();
for (const line of ["A", "B", "C", "D"]) {
await frigateApp.page
.getByRole("textbox", { name: new RegExp(`^Line ${line} distance`) })
.fill("5");
}
await speedEstimation.click();
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => requests.length).toBe(1);
expect(requests[0].body).toMatchObject({
config_data: { cameras: { front_door: { zones: { driveway: {} } } } },
});
const body = requests[0].body as {
config_data: {
cameras: { front_door: { zones: { driveway: object } } };
};
};
expect(
body.config_data.cameras.front_door.zones.driveway,
).not.toHaveProperty("distances");
});
test("renaming a referenced zone moves every reference in one request", async ({
frigateApp,
}) => {
const requests = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
await frigateApp.page
.getByRole("textbox", { name: "ID", exact: true })
.fill("front_drive");
await frigateApp.page.getByRole("button", { name: "Save" }).click();
await expect.poll(() => requests.length).toBe(1);
expect(new URL(requests[0].url).search).toBe("");
expect(requests[0].body).toEqual({
requires_restart: 1,
update_topic: "config/cameras/front_door/zones",
config_data: {
cameras: {
front_door: {
zones: {
driveway: null,
front_drive: {
coordinates: COORDINATES,
enabled: true,
filters: FILTERS,
inertia: 3,
loitering_time: 0,
},
},
review: { alerts: { required_zones: ["front_drive"] } },
snapshots: { required_zones: ["front_drive"] },
mqtt: { required_zones: ["front_drive"] },
profiles: {
armed: {
review: { alerts: { required_zones: ["front_drive"] } },
zones: {
driveway: null,
front_drive: { coordinates: COORDINATES, inertia: 6 },
},
},
},
},
},
},
});
});
test("deleting a referenced zone drops every reference in one request", async ({
frigateApp,
}) => {
const requests = await installRoutes(frigateApp.page);
await frigateApp.goto(SETTINGS_URL);
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Delete");
await frigateApp.page
.getByRole("alertdialog")
.getByRole("button", { name: "Delete" })
.click();
await expect.poll(() => requests.length).toBe(1);
expect(new URL(requests[0].url).search).toBe("");
expect(requests[0].body).toEqual({
requires_restart: 1,
update_topic: "config/cameras/front_door/zones",
config_data: {
cameras: {
front_door: {
zones: { driveway: null },
review: { alerts: { required_zones: [] } },
snapshots: { required_zones: [] },
mqtt: { required_zones: [] },
profiles: {
armed: {
review: { alerts: { required_zones: [] } },
zones: { driveway: null },
},
},
},
},
},
});
});
});
+248 -1638
View File
File diff suppressed because it is too large Load Diff
+12 -25
View File
@@ -12,8 +12,6 @@
"lint:fix": "eslint --fix .",
"preview": "vite preview",
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",
"test": "vitest",
"coverage": "vitest run --coverage",
"e2e:build": "tsc && vite build --base=/",
"e2e": "playwright test --config e2e/playwright.config.ts",
"e2e:ui": "playwright test --config e2e/playwright.config.ts --ui",
@@ -24,7 +22,7 @@
},
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^3.10.0",
"@hookform/resolvers": "^5.9.1",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.11",
@@ -57,14 +55,14 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"copy-to-clipboard": "^4.0.2",
"date-fns": "^3.6.0",
"date-fns": "^4.4.0",
"date-fns-tz": "^3.2.0",
"framer-motion": "^13.3.0",
"hls.js": "^1.7.3",
"i18next": "^24.2.0",
"i18next": "^26.4.2",
"i18next-http-backend": "^4.0.2",
"idb-keyval": "^6.3.0",
"js-yaml": "^4.3.2",
"js-yaml": "^5.4.2",
"konva": "^10.5.0",
"lodash": "^4.18.1",
"lucide-react": "^1.46.0",
@@ -76,21 +74,19 @@
"react-day-picker": "^9.14.0",
"react-device-detect": "^2.2.3",
"react-dom": "^19.3.0",
"react-dropzone": "^14.3.8",
"react-dropzone": "^20.1.2",
"react-grid-layout": "^2.2.4",
"react-hook-form": "^7.88.0",
"react-i18next": "^15.2.0",
"react-i18next": "^17.0.14",
"react-icons": "^5.7.0",
"react-konva": "^19.2.7",
"react-markdown": "^9.0.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.30.6",
"react-swipeable": "^7.0.2",
"react-zoom-pan-pinch": "3.4.4",
"remark-gfm": "^4.0.0",
"scroll-into-view-if-needed": "^3.1.0",
"sonner": "^2.0.8",
"sort-by": "^1.2.0",
"strftime": "^0.10.3",
"swr": "^2.5.1",
"tailwind-merge": "^2.4.0",
"tailwind-scrollbar": "^3.1.0",
@@ -99,21 +95,17 @@
"vaul": "^1.1.2",
"virtua": "^0.51.3",
"vite-plugin-monaco-editor": "^1.1.0",
"zod": "^3.23.8"
"zod": "^4.6.5"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.63.0",
"@tailwindcss/forms": "^0.5.11",
"@testing-library/jest-dom": "^6.6.2",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.17.25",
"@types/node": "^25.9.6",
"@types/node": "^26.5.1",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/strftime": "^0.9.8",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/coverage-v8": "^4.1.11",
"autoprefixer": "^10.6.0",
"esbuild": "^0.28.2",
"eslint": "^10.10.0",
@@ -121,22 +113,17 @@
"eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"fake-indexeddb": "^6.0.0",
"globals": "^17.12.0",
"i18next-cli": "^1.5.11",
"jest-websocket-mock": "^2.5.0",
"jsdom": "^24.1.1",
"monaco-editor": "^0.52.2",
"msw": "^2.3.5",
"patch-package": "^8.0.1",
"postcss": "^8.5.12",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5",
"prettier": "^3.9.6",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^3.4.9",
"typescript": "^5.9.3",
"typescript-eslint": "^8.70.0",
"vite": "^8.3.0",
"vitest": "^4.1.11"
"vite": "^8.3.0"
},
"overrides": {
"@radix-ui/react-compose-refs": "1.1.2",
@@ -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:",
+34 -3
View File
@@ -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": {
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEmbeddingsReindexProgress } from "@/api/ws";
import {
StatusBarMessagesContext,
StatusMessage,
} from "@/context/statusbar-provider";
} from "@/context/statusbar-context";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import StatusBarNotices from "@/components/health/StatusBarNotices";
import { cn } from "@/lib/utils";
@@ -4,13 +4,15 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { CameraConfig } from "@/types/frigateConfig";
import { useZoneFriendlyName } from "@/hooks/use-zone-friendly-name";
interface CameraNameLabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
interface CameraNameLabelProps extends React.ComponentPropsWithoutRef<
typeof LabelPrimitive.Root
> {
camera?: string | CameraConfig;
}
interface ZoneNameLabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
interface ZoneNameLabelProps extends React.ComponentPropsWithoutRef<
typeof LabelPrimitive.Root
> {
zone: string;
camera?: string;
}
@@ -163,8 +163,18 @@ export default function ClassificationModelEditDialog({
}
}, [isObjectModel, t]);
const form = useForm<ObjectFormData | StateFormData>({
resolver: zodResolver(formSchema),
const form = useForm<
ObjectFormData | StateFormData,
unknown,
ObjectFormData | StateFormData
>({
resolver: zodResolver(
formSchema as z.ZodType<
ObjectFormData | StateFormData,
z.ZodTypeDef,
ObjectFormData | StateFormData
>,
),
defaultValues: isObjectModel
? ({
enabled: model.enabled,
@@ -68,8 +68,7 @@ const review: SectionConfigOverrides = {
position: "after",
condition: (ctx) => {
const genai = ctx.formData?.genai as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
if (genai?.image_source !== "recordings") return false;
if (ctx.level === "camera" && ctx.fullCameraConfig) {
return ctx.fullCameraConfig.record?.enabled === false;
@@ -17,11 +17,9 @@ export function validateDetectDimensions(
const height = data.height;
const widthErrors = errors.width as
| { addError?: (message: string) => void }
| undefined;
{ addError?: (message: string) => void } | undefined;
const heightErrors = errors.height as
| { addError?: (message: string) => void }
| undefined;
{ addError?: (message: string) => void } | undefined;
const message = t("detect.dimensionMustBeEven", { ns: "config/validation" });
@@ -38,8 +38,7 @@ export default function CameraReviewClassification({
const cameraName = formContext?.cameraName ?? selectedCamera;
const fullFormData = formContext?.formData as JsonObject | undefined;
const baselineFormData = formContext?.baselineFormData as
| JsonObject
| undefined;
JsonObject | undefined;
const cameraConfig = formContext?.fullCameraConfig;
const alertsZones = useMemo(
@@ -10,7 +10,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
@@ -371,9 +371,9 @@ export default function NotificationsSettingsExtras({
const shouldFetchPubKey = Boolean(
config &&
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
(watchAllEnabled ||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
(watchAllEnabled ||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
);
const { data: publicKey } = useSWR(
@@ -61,7 +61,7 @@ import {
JsonValue,
} from "@/types/configForm";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
import {
cameraUpdateTopicMap,
globalCameraDefaultSections,
@@ -1,5 +1,5 @@
import type { FieldPathList, FieldProps } from "@rjsf/utils";
import yaml from "js-yaml";
import { dump, load, YAMLException } from "js-yaml";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -16,7 +16,7 @@ function formatYaml(value: unknown): string {
return "";
}
try {
return yaml.dump(value, { indent: 2, lineWidth: -1 }).trimEnd();
return dump(value, { indent: 2, lineWidth: -1 }).trimEnd();
} catch {
return "";
}
@@ -31,7 +31,7 @@ function parseYaml(text: string): {
return { value: {}, error: undefined };
}
try {
const parsed = yaml.load(trimmed);
const parsed = load(trimmed);
if (
typeof parsed !== "object" ||
parsed === null ||
@@ -41,7 +41,7 @@ function parseYaml(text: string): {
}
return { value: parsed as Record<string, unknown>, error: undefined };
} catch (e) {
const msg = e instanceof yaml.YAMLException ? e.reason : "Invalid YAML";
const msg = e instanceof YAMLException ? e.reason : "Invalid YAML";
return { value: undefined, error: msg };
}
}
@@ -217,8 +217,7 @@ function PlateCombobox({
export function KnownPlatesField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
ConfigFormContext | undefined;
const configNamespace =
formContext?.i18nNamespace ??
@@ -159,8 +159,7 @@ function StreamValueCombobox({
export function LiveStreamsField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
ConfigFormContext | undefined;
const configNamespace =
formContext?.i18nNamespace ??
@@ -96,8 +96,7 @@ const getItemProperties = (
const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => {
const scene = getItemProperties(itemSchema).scene as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const values = scene?.enum;
return Array.isArray(values)
@@ -44,8 +44,7 @@ function getPropertyTitle(itemSchema: RJSFSchema | undefined, key: string) {
export function ReplaceRulesField(props: FieldProps) {
const { schema, formData, onChange, idSchema, disabled, readonly } = props;
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
ConfigFormContext | undefined;
const configNamespace =
formContext?.i18nNamespace ??
@@ -86,8 +86,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
const formContext = registry?.formContext as ConfigFormContext | undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const sectionI18nPrefix = formContext?.sectionI18nPrefix as
| string
| undefined;
string | undefined;
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
const { t, i18n } = useTranslation([
@@ -25,8 +25,7 @@ export function MultiSchemaFieldTemplate<
const { schema, selector, optionSchemaField, uiSchema } = props;
const uiOptions = uiSchema?.["ui:options"] as
| UiSchema["ui:options"]
| undefined;
UiSchema["ui:options"] | undefined;
const suppressMultiSchema = uiOptions?.suppressMultiSchema === true;
// Check if this is a simple nullable field that should be handled specially
@@ -55,8 +55,7 @@ export function CameraPathWidget(props: WidgetProps) {
const [showCredentials, setShowCredentials] = useState(false);
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
ConfigFormContext | undefined;
const isCameraLevel = formContext?.level === "camera";
const cameraName = formContext?.cameraName;
const inputIndex = useMemo(() => getInputIndexFromWidgetId(id), [id]);
@@ -103,8 +103,7 @@ const normalizeManualText = (value: unknown): string => {
export function FfmpegArgsWidget(props: WidgetProps) {
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
ConfigFormContext | undefined;
const i18nNamespace = formContext?.i18nNamespace as string | undefined;
const isCameraLevel = formContext?.level === "camera";
const effectiveNamespace = isCameraLevel ? "config/cameras" : i18nNamespace;
@@ -27,8 +27,7 @@ import type { GenAIModelsResponse } from "@/types/chat";
import { getSizedFieldClassName } from "../utils";
type ProbeResponse =
| { success: true; models: string[] }
| { success: false; message: string };
{ success: true; models: string[] } | { success: false; message: string };
type ProbeStatus = "idle" | "probing" | "success" | "error";
@@ -21,8 +21,7 @@ export function OnvifProfileWidget(props: WidgetProps) {
const { t } = useTranslation(["views/settings"]);
const formContext = props.registry?.formContext as
| ConfigFormContext
| undefined;
ConfigFormContext | undefined;
const cameraName = formContext?.cameraName;
const isCameraLevel = formContext?.level === "camera";
const hasOnvifHost = !!formContext?.fullCameraConfig?.onvif?.host;
@@ -98,8 +98,7 @@ export function SwitchesWidget(props: WidgetProps) {
const availableEntities = useMemo(() => {
const getEntities =
(props.options?.getEntities as
| ((context: FormContext) => string[])
| undefined) || (() => []);
((context: FormContext) => string[]) | undefined) || (() => []);
if (context) {
return getEntities(context);
}
@@ -109,8 +108,8 @@ export function SwitchesWidget(props: WidgetProps) {
const getDisplayLabel = useMemo(
() =>
(props.options?.getDisplayLabel as
| ((entity: string, context?: FormContext) => string)
| undefined) || ((entity: string) => entity),
((entity: string, context?: FormContext) => string) | undefined) ||
((entity: string) => entity),
[props.options],
);
+5 -1
View File
@@ -27,6 +27,8 @@ type NameAndIdFieldsProps<T extends FieldValues = FieldValues> = {
placeholderId?: string;
idVisible?: boolean;
idDisabled?: boolean;
// Derive the id from the name as the user types
autoFillId?: boolean;
};
export default function NameAndIdFields<T extends FieldValues = FieldValues>({
@@ -43,6 +45,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
placeholderId,
idVisible,
idDisabled,
autoFillId = true,
}: NameAndIdFieldsProps<T>) {
const { t } = useTranslation(["common"]);
const { watch, setValue, trigger, formState } = useFormContext<T>();
@@ -61,7 +64,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
const effectiveProcessId = processId || defaultProcessId;
useEffect(() => {
if (idDisabled) {
if (idDisabled || !autoFillId) {
return;
}
const subscription = watch((value, { name }) => {
@@ -81,6 +84,7 @@ export default function NameAndIdFields<T extends FieldValues = FieldValues>({
idField,
effectiveProcessId,
idDisabled,
autoFillId,
]);
// Auto-expand if there's an error on the ID field after user has typed
+37 -28
View File
@@ -179,6 +179,9 @@ export default function LiveContextMenu({
],
);
const isForcedLowBandwidth =
groupStreamingSettings?.[camera]?.playerMode === "jsmpeg";
// ui
const audioControlsUsed = useRef(false);
@@ -270,12 +273,14 @@ export default function LiveContextMenu({
<div className="text-primary-variant smart-capitalize">
<CameraNameLabel camera={camera} />
</div>
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<div className="flex flex-row items-center gap-1">
<IoIosWarning className="mr-1 size-4 text-danger" />
<p className="mr-2 text-xs">{t("lowBandwidthMode")}</p>
</div>
)}
{preferredLiveMode == "jsmpeg" &&
isRestreamed &&
!isForcedLowBandwidth && (
<div className="flex flex-row items-center gap-1">
<IoIosWarning className="mr-1 size-4 text-danger" />
<p className="mr-2 text-xs">{t("lowBandwidthMode")}</p>
</div>
)}
</div>
{preferredLiveMode != "jsmpeg" && isRestreamed && supportsAudio && (
<>
@@ -374,21 +379,23 @@ export default function LiveContextMenu({
</ContextMenuItem>
</>
)}
{preferredLiveMode == "jsmpeg" && isRestreamed && (
<>
<ContextMenuSeparator />
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
<div className="text-primary">
{t("button.reset", { ns: "common" })}
{preferredLiveMode == "jsmpeg" &&
isRestreamed &&
!isForcedLowBandwidth && (
<>
<ContextMenuSeparator />
<ContextMenuItem disabled={!isEnabled}>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2"
onClick={isEnabled ? resetPreferredLiveMode : undefined}
>
<div className="text-primary">
{t("button.reset", { ns: "common" })}
</div>
</div>
</div>
</ContextMenuItem>
</>
)}
</ContextMenuItem>
</>
)}
{notificationsEnabledInConfig && isEnabled && (
<>
<ContextMenuSeparator />
@@ -548,14 +555,16 @@ export default function LiveContextMenu({
</ContextMenu>
<Dialog open={showSettings} onOpenChange={setShowSettings}>
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
streamMetadata={streamMetadata}
/>
{showSettings && (
<CameraStreamingDialog
camera={camera}
groupStreamingSettings={groupStreamingSettings}
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
streamMetadata={streamMetadata}
/>
)}
</Dialog>
</div>
);
+1 -1
View File
@@ -19,7 +19,7 @@ import useNavigation from "@/hooks/use-navigation";
import {
StatusBarMessagesContext,
StatusMessage,
} from "@/context/statusbar-provider";
} from "@/context/statusbar-context";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
@@ -174,7 +174,6 @@ export default function JSMpegPlayer({
streamType: "jsmpeg",
bandwidth: Math.round(bitrate),
totalFrames: frameCount,
latency: undefined,
droppedFrames: undefined,
decodedFrames: undefined,
droppedFrameRate: undefined,
+9 -2
View File
@@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import {
LivePlayerError,
TwoWayTalkError,
LivePlayerMode,
PlayerStatsType,
VideoResolutionType,
@@ -51,6 +52,7 @@ type LivePlayerProps = {
onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void;
onResetLiveMode?: () => void;
};
@@ -76,6 +78,7 @@ export default function LivePlayer({
onClick,
setFullResolution,
onError,
onMicrophoneError,
onResetLiveMode,
}: LivePlayerProps) {
const { t } = useTranslation(["components/player"]);
@@ -98,7 +101,6 @@ export default function LivePlayer({
const [stats, setStats] = useState<PlayerStatsType>({
streamType: "-",
bandwidth: 0, // in kBps
latency: undefined, // in seconds
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
@@ -272,6 +274,7 @@ export default function LivePlayer({
onPlaying={playerIsPlaying}
pip={pip}
onError={onError}
onMicrophoneError={onMicrophoneError}
/>
);
} else if (preferredLiveMode == "mse") {
@@ -363,7 +366,11 @@ export default function LivePlayer({
{cameraEnabled &&
!offline &&
(!showStillWithoutActivity || isReEnabling) &&
!liveReady && <ActivityIndicator />}
!liveReady && (
<div className="absolute inset-0 flex items-center justify-center">
<ActivityIndicator />
</div>
)}
{((showStillWithoutActivity && !liveReady) || liveReady) &&
objects.length > 0 && (
-11
View File
@@ -759,15 +759,6 @@ function MSEPlayer({
lastLoadedBytes = bytesLoaded;
lastTimestamp = now;
const latency =
video.seekable.length > 0
? Math.max(
0,
video.seekable.end(video.seekable.length - 1) -
video.currentTime,
)
: 0;
const videoQuality = video.getVideoPlaybackQuality();
const { totalVideoFrames, droppedVideoFrames } = videoQuality;
const droppedFrameRate = totalVideoFrames
@@ -777,7 +768,6 @@ function MSEPlayer({
setStats?.({
streamType: "MSE",
bandwidth,
latency,
totalFrames: totalVideoFrames,
droppedFrames: droppedVideoFrames || undefined,
decodedFrames: totalVideoFrames - droppedVideoFrames,
@@ -793,7 +783,6 @@ function MSEPlayer({
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
+7 -27
View File
@@ -8,27 +8,21 @@ type PlayerStatsProps = {
};
export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
const { t } = useTranslation(["components/player"]);
const { t } = useTranslation(["components/player", "views/live"]);
const streamTypeLabel = t(
`stream.technology.name.${stats.streamType.toLowerCase()}`,
{ ns: "views/live", defaultValue: stats.streamType },
);
const fullStatsContent = (
<>
<p>
<span className="text-white/70">{t("stats.streamType.title")}</span>{" "}
<span className="text-white">{stats.streamType}</span>
<span className="text-white">{streamTypeLabel}</span>
</p>
<p>
<span className="text-white/70">{t("stats.bandwidth.title")}</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kBps</span>
</p>
{stats.latency != undefined && (
<p>
<span className="text-white/70">{t("stats.latency.title")}</span>{" "}
<span
className={`text-white ${stats.latency > 2 ? "text-danger" : ""}`}
>
{t("stats.latency.value", { seconds: stats.latency.toFixed(2) })}
</span>
</p>
)}
<p>
<span className="text-white/70">{t("stats.totalFrames")}</span>{" "}
<span className="text-white">{stats.totalFrames}</span>
@@ -62,26 +56,12 @@ export function PlayerStats({ stats, minimal }: PlayerStatsProps) {
<div className="flex flex-row items-center justify-center gap-4">
<div className="flex flex-col items-center justify-start gap-1">
<span className="text-white/70">{t("stats.streamType.short")}</span>
<span className="text-white">{stats.streamType}</span>
<span className="text-white">{streamTypeLabel}</span>
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-white/70">{t("stats.bandwidth.short")}</span>{" "}
<span className="text-white">{stats.bandwidth.toFixed(2)} kBps</span>
</div>
{stats.latency != undefined && (
<div className="hidden flex-col items-center gap-1 md:flex">
<span className="text-white/70">
{t("stats.latency.short.title")}
</span>
<span
className={`text-white ${stats.latency >= 2 ? "text-danger" : ""}`}
>
{t("stats.latency.short.value", {
seconds: stats.latency.toFixed(2),
})}
</span>
</div>
)}
{stats.droppedFrames != undefined && (
<div className="flex flex-col items-center justify-end gap-1">
<span className="text-white/70">
@@ -0,0 +1,13 @@
export abstract class PreviewController {
public camera = "";
constructor(camera: string) {
this.camera = camera;
}
abstract scrubToTimestamp(time: number): boolean;
abstract finishedSeeking(): void;
abstract setNewPreviewStartTime(time: number): void;
}
+1 -14
View File
@@ -22,6 +22,7 @@ import {
} from "@/hooks/use-camera-previews";
import { useTranslation } from "react-i18next";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { PreviewController } from "./PreviewController";
type PreviewPlayerProps = {
previewRef?: (ref: HTMLDivElement | null) => void;
@@ -102,20 +103,6 @@ export default function PreviewPlayer({
);
}
export abstract class PreviewController {
public camera = "";
constructor(camera: string) {
this.camera = camera;
}
abstract scrubToTimestamp(time: number): boolean;
abstract finishedSeeking(): void;
abstract setNewPreviewStartTime(time: number): void;
}
type PreviewVideoPlayerProps = {
visibilityRef?: (ref: HTMLDivElement | null) => void;
className?: string;
@@ -0,0 +1,96 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { LivePlayerMode, WebRTCUnavailableReason } from "@/types/live";
import { LuX } from "react-icons/lu";
import { useTranslation } from "react-i18next";
type StreamTechnologySelectProps = {
value: LivePlayerMode;
onValueChange: (value: LivePlayerMode) => void;
isWebRTCAvailable: boolean;
webRTCUnavailableReason?: WebRTCUnavailableReason;
disabled: boolean;
};
export default function StreamTechnologySelect({
value,
onValueChange,
isWebRTCAvailable,
webRTCUnavailableReason,
disabled,
}: StreamTechnologySelectProps) {
const { t } = useTranslation(["views/live"]);
const isChecking = webRTCUnavailableReason === "checking";
return (
<Select
value={value}
onValueChange={onValueChange as (value: string) => void}
disabled={disabled}
>
<SelectTrigger className="w-full">
<span>{t(`stream.technology.name.${value}`)}</span>
</SelectTrigger>
<SelectContent className="w-[var(--radix-select-trigger-width)]">
<SelectItem value="mse">
<span className="flex flex-col gap-0.5 whitespace-normal">
<span>{t("stream.technology.name.mse")}</span>
<span className="text-xs text-muted-foreground">
{t("stream.technology.tips.mse")}
</span>
</span>
</SelectItem>
<SelectItem
value="webrtc"
disabled={!isWebRTCAvailable}
className="data-[disabled]:opacity-100"
>
<span className="flex flex-col gap-0.5 whitespace-normal">
<span className={cn(!isWebRTCAvailable && "opacity-50")}>
{t("stream.technology.name.webrtc")}
</span>
<span
className={cn(
"text-xs text-muted-foreground",
!isWebRTCAvailable && "opacity-50",
)}
>
{t("stream.technology.tips.webrtc")}
</span>
{!isWebRTCAvailable && webRTCUnavailableReason && (
<span
className={cn(
"mt-1 flex flex-row items-start gap-1.5 rounded-md border p-2",
isChecking
? "border-secondary-foreground/20 bg-secondary-foreground/10"
: "border-danger/20 bg-danger/10",
)}
>
{isChecking ? (
<ActivityIndicator
className="mt-0.5 size-3.5 shrink-0"
size={14}
/>
) : (
<LuX className="mt-0.5 size-3.5 shrink-0 text-danger" />
)}
<span className="text-xs text-secondary-foreground">
{t(
`stream.technology.unavailable.${webRTCUnavailableReason}`,
)}
</span>
</span>
)}
</span>
</SelectItem>
</SelectContent>
</Select>
);
}
+147 -60
View File
@@ -1,6 +1,13 @@
import { baseUrl } from "@/api/baseUrl";
import { LivePlayerError, PlayerStatsType } from "@/types/live";
import {
LivePlayerError,
PlayerStatsType,
TwoWayTalkError,
} from "@/types/live";
import { FrigateConfig } from "@/types/frigateConfig";
import { webRTCIceServers } from "@/utils/webrtcUtil";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
type WebRtcPlayerProps = {
className?: string;
@@ -15,6 +22,7 @@ type WebRtcPlayerProps = {
setStats?: (stats: PlayerStatsType) => void;
onPlaying?: () => void;
onError?: (error: LivePlayerError) => void;
onMicrophoneError?: (error: TwoWayTalkError) => void;
};
export default function WebRtcPlayer({
@@ -30,9 +38,22 @@ export default function WebRtcPlayer({
setStats,
onPlaying,
onError,
onMicrophoneError,
}: WebRtcPlayerProps) {
// metadata
const { data: config } = useSWR<FrigateConfig>("config");
// Keyed on the serialized list so an unrelated config update doesn't
// reconnect every WebRTC player.
const iceServersKey = JSON.stringify(
config?.go2rtc?.webrtc?.ice_servers ?? [],
);
const iceServers = useMemo(
() => webRTCIceServers(JSON.parse(iceServersKey)),
[iceServersKey],
);
const wsURL = useMemo(() => {
return `${baseUrl.replace(/^http/, "ws")}live/webrtc/api/ws?src=${camera}`;
}, [camera]);
@@ -54,6 +75,10 @@ export default function WebRtcPlayer({
const pcRef = useRef<RTCPeerConnection | undefined>(undefined);
const wsRef = useRef<WebSocket | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
// Separate sendonly-audio connection for two-way talk: go2rtc only wires the
// backchannel from a connection's initial offer.
const micPcRef = useRef<RTCPeerConnection | undefined>(undefined);
const micWsRef = useRef<WebSocket | null>(null);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const videoLoadTimeoutRef = useRef<NodeJS.Timeout>(undefined);
@@ -65,7 +90,7 @@ export default function WebRtcPlayer({
const pc = new RTCPeerConnection({
bundlePolicy: "max-bundle",
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
iceServers,
});
const localTracks = [];
@@ -105,7 +130,7 @@ export default function WebRtcPlayer({
videoRef.current.srcObject = new MediaStream(localTracks);
return pc;
},
[videoRef],
[videoRef, iceServers],
);
async function getMediaTracks(
@@ -123,51 +148,57 @@ export default function WebRtcPlayer({
}
}
// Offer/answer/ICE exchange over the WebSocket; shared by both connections.
const startSignaling = useCallback((pc: RTCPeerConnection, ws: WebSocket) => {
ws.addEventListener("open", () => {
pc.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) return;
ws.send(
JSON.stringify({
type: "webrtc/candidate",
value: ev.candidate.candidate,
}),
);
});
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer))
.then(() => {
ws.send(
JSON.stringify({
type: "webrtc/offer",
value: pc.localDescription?.sdp,
}),
);
});
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "webrtc/candidate") {
pc.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
} else if (msg.type === "webrtc/answer") {
pc.setRemoteDescription({ type: "answer", sdp: msg.value });
}
});
}, []);
const connect = useCallback(
async (aPc: Promise<RTCPeerConnection | undefined>) => {
if (!aPc) {
return;
}
pcRef.current = await aPc;
const pc = await aPc;
if (!pc) {
return;
}
pcRef.current = pc;
wsRef.current = new WebSocket(wsURL);
const ws = wsRef.current;
ws.addEventListener("open", () => {
pcRef.current?.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) return;
const msg = {
type: "webrtc/candidate",
value: ev.candidate.candidate,
};
ws.send(JSON.stringify(msg));
});
pcRef.current
?.createOffer()
.then((offer) => pcRef.current?.setLocalDescription(offer))
.then(() => {
const msg = {
type: "webrtc/offer",
value: pcRef.current?.localDescription?.sdp,
};
ws.send(JSON.stringify(msg));
});
});
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "webrtc/candidate") {
pcRef.current?.addIceCandidate({ candidate: msg.value, sdpMid: "0" });
} else if (msg.type === "webrtc/answer") {
pcRef.current?.setRemoteDescription({
type: "answer",
sdp: msg.value,
});
}
});
startSignaling(pc, wsRef.current);
},
[wsURL],
[wsURL, startSignaling],
);
useEffect(() => {
@@ -179,9 +210,8 @@ export default function WebRtcPlayer({
return;
}
const aPc = PeerConnection(
microphoneEnabled ? "video+audio+microphone" : "video+audio",
);
// No mic here. It's a separate connection, so toggling talk never reloads.
const aPc = PeerConnection("video+audio");
connect(aPc);
return () => {
@@ -194,14 +224,80 @@ export default function WebRtcPlayer({
pcRef.current = undefined;
}
};
}, [camera, connect, PeerConnection, pcRef, videoRef, playbackEnabled]);
// Backchannel connection, alive only while the mic is on.
useEffect(() => {
if (!microphoneEnabled || !playbackEnabled) {
return;
}
let cancelled = false;
(async () => {
const tracks = await getMediaTracks("user", {
video: false,
audio: true,
});
if (cancelled) {
tracks.forEach((track) => track.stop());
return;
}
if (tracks.length === 0) {
onMicrophoneError?.("microphone");
return;
}
const pc = new RTCPeerConnection({
bundlePolicy: "max-bundle",
iceServers,
});
tracks.forEach((track) =>
pc.addTransceiver(track, { direction: "sendonly" }),
);
micPcRef.current = pc;
const ws = new WebSocket(wsURL);
micWsRef.current = ws;
startSignaling(pc, ws);
// go2rtc sends an error instead of an answer when it can't attach the
// microphone to the camera's backchannel.
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type !== "error" || cancelled) {
return;
}
// eslint-disable-next-line no-console
console.error(
`${camera} - Two-way talk error: ${msg.value} See the documentation: https://docs.frigate.video/configuration/live/#two-way-talk`,
);
onMicrophoneError?.("refused");
});
})();
return () => {
cancelled = true;
micPcRef.current?.getSenders().forEach((sender) => sender.track?.stop());
if (micWsRef.current) {
micWsRef.current.close();
micWsRef.current = null;
}
if (micPcRef.current) {
micPcRef.current.close();
micPcRef.current = undefined;
}
};
}, [
camera,
connect,
PeerConnection,
pcRef,
videoRef,
playbackEnabled,
microphoneEnabled,
playbackEnabled,
wsURL,
startSignaling,
iceServers,
camera,
onMicrophoneError,
]);
// ios compat
@@ -262,9 +358,7 @@ export default function WebRtcPlayer({
const report = await pcRef.current.getStats();
let bytesReceived = 0;
let timestamp = 0;
let roundTripTime = 0;
let framesReceived = 0;
let framesDropped = 0;
let framesDecoded = 0;
report.forEach((stat) => {
@@ -272,12 +366,8 @@ export default function WebRtcPlayer({
bytesReceived = stat.bytesReceived;
timestamp = stat.timestamp;
framesReceived = stat.framesReceived;
framesDropped = stat.framesDropped;
framesDecoded = stat.framesDecoded;
}
if (stat.type === "candidate-pair" && stat.state === "succeeded") {
roundTripTime = stat.currentRoundTripTime;
}
});
const timeDiff = (timestamp - lastTimestamp) / 1000; // in seconds
@@ -289,12 +379,10 @@ export default function WebRtcPlayer({
setStats?.({
streamType: "WebRTC",
bandwidth: Math.round(bitrate),
latency: roundTripTime,
totalFrames: framesReceived,
droppedFrames: framesDropped,
droppedFrames: undefined,
decodedFrames: framesDecoded,
droppedFrameRate:
framesReceived > 0 ? (framesDropped / framesReceived) * 100 : 0,
droppedFrameRate: undefined,
});
lastBytesReceived = bytesReceived;
@@ -307,7 +395,6 @@ export default function WebRtcPlayer({
setStats?.({
streamType: "-",
bandwidth: 0,
latency: undefined,
totalFrames: 0,
droppedFrames: undefined,
decodedFrames: 0,
@@ -8,11 +8,7 @@
*/
export type DownswitchReason =
| "stall"
| "bandwidth"
| "fatal-error"
| "startup"
| "codec";
"stall" | "bandwidth" | "fatal-error" | "startup" | "codec";
// stalls just after a seek are expected on any network (the target
// position is rarely buffered), so they get a longer budget and are
@@ -1,6 +1,6 @@
import { Recording } from "@/types/record";
import { DynamicPlayback } from "@/types/playback";
import { PreviewController } from "../PreviewPlayer";
import { PreviewController } from "../PreviewController";
import { TimeRange, TrackingDetailsSequence } from "@/types/timeline";
import {
calculateInpointOffset,
@@ -16,7 +16,8 @@ import {
RecordingCoverage,
} from "@/types/record";
import { Preview } from "@/types/preview";
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
import PreviewPlayer from "../PreviewPlayer";
import { PreviewController } from "../PreviewController";
import { DynamicVideoController } from "./DynamicVideoController";
import HlsVideoPlayer, { HlsSource } from "../HlsVideoPlayer";
import { useDetailStream } from "@/context/detail-stream-context";
@@ -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")}
@@ -65,7 +65,7 @@ import {
import { buildConfigDataForPath } from "@/utils/configUtil";
import { useConfigSchema } from "@/hooks/use-config-schema";
import { useRestart } from "@/api/ws";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import SaveAllPreviewPopover from "@/components/overlay/detail/SaveAllPreviewPopover";
+76 -141
View File
@@ -22,12 +22,12 @@ import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
import { isMobile } from "react-device-detect";
import { toRGBColorString } from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useContext, useMemo, useState } from "react";
import axios from "axios";
import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { removeRequiredZoneQuery, reviewQueries } from "@/utils/zoneEdutUtil";
import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
import IconWrapper from "../ui/icon-wrapper";
import { buttonVariants } from "@/components/ui/button";
import { Trans, useTranslation } from "react-i18next";
@@ -35,6 +35,7 @@ import ActivityIndicator from "../indicators/activity-indicator";
import { cn } from "@/lib/utils";
import { useMotionMaskState, useObjectMaskState, useZoneState } from "@/api/ws";
import { getProfileColor } from "@/utils/profileColors";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
type PolygonItemProps = {
polygon: Polygon;
@@ -72,6 +73,7 @@ export default function PolygonItem({
const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const statusBar = useContext(StatusBarMessagesContext);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { payload: motionMaskState, send: sendMotionMaskState } =
useMotionMaskState(polygon.camera, polygon.name);
@@ -137,148 +139,66 @@ export default function PolygonItem({
setIsLoading(true);
setLoadingPolygonIndex(index);
let cameraUpdate: Record<string, unknown>;
let needsRestart = false;
if (polygon.type === "zone") {
let url: string;
const deleteSection = { zones: { [polygon.name]: null } };
if (editingProfile) {
// Profile mode: just delete the profile zone
url = `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`;
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
} else {
// Base mode: handle review queries
const { alertQueries, detectionQueries } = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
const genaiQueries = removeRequiredZoneQuery(
polygon.name,
polygon.camera,
"objects.genai",
cameraConfig?.objects.genai.required_zones || [],
);
const snapshotQueries = removeRequiredZoneQuery(
polygon.name,
polygon.camera,
"snapshots",
cameraConfig?.snapshots.required_zones || [],
);
const mqttQueries = removeRequiredZoneQuery(
polygon.name,
polygon.camera,
"mqtt",
cameraConfig?.mqtt.required_zones || [],
);
const autotrackQueries = removeRequiredZoneQuery(
polygon.name,
polygon.camera,
"onvif.autotracking",
cameraConfig?.onvif.autotracking.required_zones || [],
);
// Also delete from profiles that have overrides for this zone
let profileQueries = "";
const references = zoneReferenceUpdates(cameraConfig, polygon.name);
// Processes other than object tracking only reload these on restart
needsRestart = Object.keys(references).length > 0;
cameraUpdate = { ...deleteSection, ...references };
}
} else {
const deleteSection =
polygon.type === "motion_mask"
? { motion: { mask: { [polygon.name]: null } } }
: !polygon.objects.length
? { objects: { mask: { [polygon.name]: null } } }
: {
objects: {
filters: {
[polygon.objects[0]]: {
mask: { [polygon.name]: null },
},
},
},
};
if (editingProfile) {
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
} else {
// Base mode: also delete from profiles that have overrides for this mask
const profileDeletes: Record<string, unknown> = {};
if (allProfileNames && cameraConfig) {
for (const profileName of allProfileNames) {
if (
cameraConfig.profiles?.[profileName]?.zones?.[polygon.name] !==
undefined
) {
profileQueries += `&cameras.${polygon.camera}.profiles.${profileName}.zones.${polygon.name}`;
const profileData = cameraConfig.profiles?.[profileName];
if (!profileData) continue;
const hasMask =
polygon.type === "motion_mask"
? profileData.motion?.mask?.[polygon.name] !== undefined
: polygon.type === "object_mask"
? profileData.objects?.mask?.[polygon.name] !== undefined ||
Object.values(profileData.objects?.filters || {}).some(
(f) => f?.mask?.[polygon.name] !== undefined,
)
: false;
if (hasMask) {
profileDeletes[profileName] = deleteSection;
}
}
}
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}${genaiQueries}${snapshotQueries}${mqttQueries}${autotrackQueries}${profileQueries}`;
cameraUpdate =
Object.keys(profileDeletes).length > 0
? { ...deleteSection, profiles: profileDeletes }
: deleteSection;
}
await axios
.put(`config/set?${url}`, {
requires_restart: 0,
update_topic: updateTopic,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
}),
{ position: "top-center" },
);
updateConfig();
onDeleted?.();
} else {
toast.error(
t("toast.save.error.title", {
ns: "common",
errorMessage: res.statusText,
}),
{ position: "top-center" },
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
return;
}
// Motion masks and object masks use JSON body format
const deleteSection =
polygon.type === "motion_mask"
? { motion: { mask: { [polygon.name]: null } } }
: !polygon.objects.length
? { objects: { mask: { [polygon.name]: null } } }
: {
objects: {
filters: {
[polygon.objects[0]]: {
mask: { [polygon.name]: null },
},
},
},
};
let cameraUpdate: Record<string, unknown>;
if (editingProfile) {
cameraUpdate = { profiles: { [editingProfile]: deleteSection } };
} else {
// Base mode: also delete from profiles that have overrides for this mask
const profileDeletes: Record<string, unknown> = {};
if (allProfileNames && cameraConfig) {
for (const profileName of allProfileNames) {
const profileData = cameraConfig.profiles?.[profileName];
if (!profileData) continue;
const hasMask =
polygon.type === "motion_mask"
? profileData.motion?.mask?.[polygon.name] !== undefined
: polygon.type === "object_mask"
? profileData.objects?.mask?.[polygon.name] !== undefined ||
Object.values(profileData.objects?.filters || {}).some(
(f) => f?.mask?.[polygon.name] !== undefined,
)
: false;
if (hasMask) {
profileDeletes[profileName] = deleteSection;
}
}
}
cameraUpdate =
Object.keys(profileDeletes).length > 0
? { ...deleteSection, profiles: profileDeletes }
: deleteSection;
}
const configUpdate = {
@@ -290,17 +210,29 @@ export default function PolygonItem({
await axios
.put("config/set", {
config_data: configUpdate,
requires_restart: 0,
requires_restart: needsRestart ? 1 : 0,
update_topic: updateTopic,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
}),
{ position: "top-center" },
);
if (needsRestart) {
statusBar?.addMessage(
"config_restart_required",
t("configForm.restartRequiredFooter"),
undefined,
"config_restart_required",
);
toast.success(t("toast.successRestartRequired"), {
position: "top-center",
});
} else {
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
}),
{ position: "top-center" },
);
}
updateConfig();
onDeleted?.();
} else {
@@ -338,6 +270,7 @@ export default function PolygonItem({
editingProfile,
allProfileNames,
onDeleted,
statusBar,
],
);
@@ -593,6 +526,7 @@ export default function PolygonItem({
<TooltipTrigger asChild>
<IconWrapper
icon={LuPencil}
aria-label={t("button.edit", { ns: "common" })}
disabled={isLoading}
className={cn(
"size-[15px] cursor-pointer",
@@ -637,6 +571,7 @@ export default function PolygonItem({
<TooltipTrigger asChild>
<IconWrapper
icon={HiTrash}
aria-label={t("button.delete", { ns: "common" })}
disabled={isLoading}
className={cn(
"size-[15px] cursor-pointer",
+102 -155
View File
@@ -11,14 +11,14 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ZoneFormValuesType, Polygon } from "@/types/canvas";
import { reviewQueries } from "@/utils/zoneEdutUtil";
import { zoneReferenceUpdates } from "@/utils/zoneEdutUtil";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import PolygonEditControls from "./PolygonEditControls";
@@ -35,6 +35,7 @@ import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n";
import NameAndIdFields from "../input/NameAndIdFields";
import { useZoneState } from "@/api/ws";
import { StatusBarMessagesContext } from "@/context/statusbar-context";
type ZoneEditPaneProps = {
polygons?: Polygon[];
@@ -71,6 +72,7 @@ export default function ZoneEditPane({
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const statusBar = useContext(StatusBarMessagesContext);
const cameras = useMemo(() => {
if (!config) {
@@ -95,29 +97,8 @@ export default function ZoneEditPane({
const isExistingZone = !!polygon && polygon.name.length > 0;
const idDisabled = useMemo(() => {
if (!isExistingZone || !polygon) {
return false;
}
if (editingProfile) {
return true;
}
const cam = config?.cameras[polygon.camera];
if (!cam) {
return false;
}
const inRequiredZones =
cam.review.alerts.required_zones.includes(polygon.name) ||
cam.review.detections.required_zones.includes(polygon.name) ||
cam.objects.genai.required_zones.includes(polygon.name) ||
cam.snapshots.required_zones.includes(polygon.name) ||
cam.mqtt.required_zones.includes(polygon.name) ||
cam.onvif.autotracking.required_zones.includes(polygon.name);
const hasProfileOverride = Object.values(cam.profiles ?? {}).some(
(profile) => profile?.zones && polygon.name in profile.zones,
);
return inRequiredZones || hasProfileOverride;
}, [config, polygon, editingProfile, isExistingZone]);
// A profile zone overrides a base zone by name, so its id is fixed
const idDisabled = isExistingZone && !!editingProfile;
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
@@ -316,7 +297,11 @@ export default function ZoneEditPane({
return profileZone ?? cam.zones[polygon.name];
}, [polygon, config, editingProfile]);
const form = useForm<z.infer<typeof formSchema>>({
const form = useForm<
z.input<typeof formSchema>,
unknown,
z.output<typeof formSchema>
>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
@@ -366,7 +351,7 @@ export default function ZoneEditPane({
}, [polygon?.isFinished, form]);
const saveToConfig = useCallback(
async (
(
{
name: zoneName,
friendly_name,
@@ -387,169 +372,130 @@ export default function ZoneEditPane({
return;
}
// Determine config path prefix based on profile mode
const pathPrefix = editingProfile
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${zoneName}`
: `cameras.${polygon.camera}.zones.${zoneName}`;
const oldPathPrefix = editingProfile
? `cameras.${polygon.camera}.profiles.${editingProfile}.zones.${polygon.name}`
: `cameras.${polygon.camera}.zones.${polygon.name}`;
let mutatedConfig: typeof config;
let alertQueries = "";
let detectionQueries = "";
const renamingZone = zoneName != polygon.name && polygon.name != "";
// config/set fails to delete a key the YAML lacks. The base zone comes
// back with defaults filled in, so only a non-empty value proves the key
// is written. Profile overrides hold only keys set in the YAML.
const writtenZone = (
renamingZone
? undefined
: editingProfile
? cameraConfig?.profiles?.[editingProfile]?.zones?.[polygon.name]
: (cameraConfig?.base_config?.zones ?? cameraConfig?.zones)?.[
polygon.name
]
) as Record<string, unknown> | undefined;
const canRemove = (key: string) => {
const value = writtenZone?.[key];
return value != null && !(Array.isArray(value) && value.length == 0);
};
const zoneData: Record<string, unknown> = {
coordinates: flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(","),
enabled,
};
if (renamingZone) {
// rename - delete old zone and replace with new
// Only handle review queries for base config (not profiles)
if (!editingProfile) {
const zoneInAlerts =
cameraConfig?.review.alerts.required_zones.includes(polygon.name) ??
false;
const zoneInDetections =
cameraConfig?.review.detections.required_zones.includes(
polygon.name,
) ?? false;
// The form has no filters field, so carry the old zone's over
const baseZones = (cameraConfig?.base_config?.zones ??
cameraConfig?.zones) as
Record<string, { filters?: Record<string, unknown> }> | undefined;
const filters = baseZones?.[polygon.name]?.filters;
const {
alertQueries: renameAlertQueries,
detectionQueries: renameDetectionQueries,
} = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
try {
await axios.put(
`config/set?${oldPathPrefix}${renameAlertQueries}${renameDetectionQueries}`,
{
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/zones`,
},
);
// Wait for the config to be updated
mutatedConfig = await updateConfig();
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
setIsLoading(false);
return;
}
// make sure new zone name is readded to review
({ alertQueries, detectionQueries } = reviewQueries(
zoneName,
zoneInAlerts,
zoneInDetections,
polygon.camera,
mutatedConfig?.cameras[polygon.camera]?.review.alerts
.required_zones || [],
mutatedConfig?.cameras[polygon.camera]?.review.detections
.required_zones || [],
));
} else {
// Profile mode: just delete the old profile zone path
try {
await axios.put(`config/set?${oldPathPrefix}`, {
requires_restart: 0,
});
mutatedConfig = await updateConfig();
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
setIsLoading(false);
return;
}
if (filters && Object.keys(filters).length > 0) {
zoneData.filters = filters;
}
}
const coordinates = flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(",");
let objectQueries = objects
.map((object) => `&${pathPrefix}.objects=${object}`)
.join("");
const same_objects =
form_objects.length == objects.length &&
form_objects.every(function (element, index) {
return element === objects[index];
});
// deleting objects
if (!objectQueries && !same_objects && !renamingZone) {
objectQueries = `&${pathPrefix}.objects`;
if (objects.length > 0) {
zoneData.objects = objects;
} else if (!same_objects && canRemove("objects")) {
zoneData.objects = null;
}
let inertiaQuery = "";
if (inertia) {
inertiaQuery = `&${pathPrefix}.inertia=${inertia}`;
zoneData.inertia = inertia;
}
let loiteringTimeQuery = "";
if (loitering_time >= 0) {
loiteringTimeQuery = `&${pathPrefix}.loitering_time=${loitering_time}`;
if (typeof loitering_time === "number") {
zoneData.loitering_time = loitering_time;
}
let distancesQuery = "";
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
if (speedEstimation) {
distancesQuery = `&${pathPrefix}.distances=${distances}`;
} else {
if (distances != "") {
distancesQuery = `&${pathPrefix}.distances`;
}
zoneData.distances = distances;
} else if (canRemove("distances")) {
zoneData.distances = null;
}
let speedThresholdQuery = "";
if (speed_threshold >= 0 && speedEstimation) {
speedThresholdQuery = `&${pathPrefix}.speed_threshold=${speed_threshold}`;
} else {
if (resolvedZoneData?.speed_threshold) {
speedThresholdQuery = `&${pathPrefix}.speed_threshold`;
}
if (typeof speed_threshold === "number" && speedEstimation) {
zoneData.speed_threshold = speed_threshold;
} else if (canRemove("speed_threshold")) {
zoneData.speed_threshold = null;
}
let friendlyNameQuery = "";
if (friendly_name && friendly_name !== zoneName) {
friendlyNameQuery = `&${pathPrefix}.friendly_name=${encodeURIComponent(friendly_name)}`;
// The name field falls back to the old id when the zone has no name
const unnamed = !polygon.friendly_name && friendly_name === polygon.name;
if (friendly_name && friendly_name !== zoneName && !unnamed) {
zoneData.friendly_name = friendly_name;
}
const enabledQuery = `&${pathPrefix}.enabled=${enabled ? "True" : "False"}`;
const zones = renamingZone
? { [polygon.name]: null, [zoneName]: zoneData }
: { [zoneName]: zoneData };
const references =
renamingZone && !editingProfile && cameraConfig
? zoneReferenceUpdates(cameraConfig, polygon.name, zoneName)
: {};
// Processes other than object tracking only reload these on restart
const needsRestart = Object.keys(references).length > 0;
const cameraData = editingProfile
? { profiles: { [editingProfile]: { zones } } }
: { zones, ...references };
const updateTopic = editingProfile
? undefined
: `config/cameras/${polygon.camera}/zones`;
axios
.put(
`config/set?${pathPrefix}.coordinates=${coordinates}${enabledQuery}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${friendlyNameQuery}${alertQueries}${detectionQueries}`,
{
requires_restart: 0,
update_topic: updateTopic,
},
)
.put("config/set", {
config_data: { cameras: { [polygon.camera]: cameraData } },
requires_restart: needsRestart ? 1 : 0,
update_topic: updateTopic,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("masksAndZones.zones.toast.success", {
zoneName: friendly_name || zoneName,
}),
{
if (needsRestart) {
statusBar?.addMessage(
"config_restart_required",
t("configForm.restartRequiredFooter"),
undefined,
"config_restart_required",
);
toast.success(t("toast.successRestartRequired"), {
position: "top-center",
},
);
});
} else {
toast.success(
t("masksAndZones.zones.toast.success", {
zoneName: friendly_name || zoneName,
}),
{
position: "top-center",
},
);
}
updateConfig();
// Only publish WS state for base config when zone has a name and
// wasn't renamed (the hook is bound to the old name).
@@ -597,11 +543,11 @@ export default function ZoneEditPane({
t,
sendZoneState,
editingProfile,
resolvedZoneData,
statusBar,
],
);
function onSubmit(values: z.infer<typeof formSchema>) {
function onSubmit(values: z.output<typeof formSchema>) {
if (activePolygonIndex === undefined || !values || !polygons) {
return;
}
@@ -674,6 +620,7 @@ export default function ZoneEditPane({
nameDescription={t("masksAndZones.zones.name.tips")}
placeholderName={t("masksAndZones.zones.name.inputPlaceHolder")}
idDisabled={idDisabled}
autoFillId={!isExistingZone}
/>
<FormField
control={form.control}
+3 -3
View File
@@ -1,5 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio }
export { AspectRatio };
+2 -1
View File
@@ -24,7 +24,8 @@ const badgeVariants = cva(
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
+2 -1
View File
@@ -35,7 +35,8 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
+9 -9
View File
@@ -1,8 +1,8 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
@@ -11,8 +11,8 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
@@ -22,7 +22,7 @@ const Checkbox = React.forwardRef<
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox }
export { Checkbox };
@@ -99,7 +99,11 @@ export default function AnimatedCircularProgressBar({
</svg>
<span
data-current-value={currentPercent}
className="duration-[var(--transition-length)] delay-[var(--delay)] absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
className="absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
style={{
animationDuration: "var(--transition-length)",
animationDelay: "var(--delay)",
}}
>
{currentPercent}%
</span>
+45 -45
View File
@@ -1,41 +1,41 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
@@ -45,12 +45,12 @@ const ContextMenuSubContent = React.forwardRef<
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
@@ -61,31 +61,31 @@ const ContextMenuContent = React.forwardRef<
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className
className,
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
@@ -94,8 +94,8 @@ const ContextMenuCheckboxItem = React.forwardRef<
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
@@ -107,9 +107,9 @@ const ContextMenuCheckboxItem = React.forwardRef<
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
@@ -118,8 +118,8 @@ const ContextMenuRadioItem = React.forwardRef<
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
@@ -130,13 +130,13 @@ const ContextMenuRadioItem = React.forwardRef<
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
@@ -144,12 +144,12 @@ const ContextMenuLabel = React.forwardRef<
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
className,
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
@@ -160,8 +160,8 @@ const ContextMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
@@ -171,13 +171,13 @@ const ContextMenuShortcut = ({
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
className,
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
);
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
@@ -195,4 +195,4 @@ export {
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
};
+44 -44
View File
@@ -1,42 +1,42 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
inset && "pl-8",
className
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -46,13 +46,13 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>
))
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -66,31 +66,31 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@@ -99,8 +99,8 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
@@ -112,9 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -123,8 +123,8 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
@@ -135,13 +135,13 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
@@ -149,12 +149,12 @@ const DropdownMenuLabel = React.forwardRef<
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
className,
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@@ -165,8 +165,8 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
@@ -177,9 +177,9 @@ const DropdownMenuShortcut = ({
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
@@ -197,4 +197,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
};
+9 -9
View File
@@ -1,11 +1,11 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
@@ -17,11 +17,11 @@ const HoverCardContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent }
export { HoverCard, HoverCardTrigger, HoverCardContent };
+1 -2
View File
@@ -2,8 +2,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
+9 -9
View File
@@ -1,12 +1,12 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
@@ -18,7 +18,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label }
export { Label };
+7 -7
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
@@ -11,7 +11,7 @@ const Progress = React.forwardRef<
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
className,
)}
{...props}
>
@@ -20,7 +20,7 @@ const Progress = React.forwardRef<
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress }
export { Progress };
+12 -12
View File
@@ -1,8 +1,8 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
@@ -14,9 +14,9 @@ const RadioGroup = React.forwardRef<
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
@@ -27,7 +27,7 @@ const RadioGroupItem = React.forwardRef<
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
{...props}
>
@@ -35,8 +35,8 @@ const RadioGroupItem = React.forwardRef<
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem }
export { RadioGroup, RadioGroupItem };
+9 -9
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
@@ -18,8 +18,8 @@ const ScrollArea = React.forwardRef<
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
@@ -34,13 +34,13 @@ const ScrollBar = React.forwardRef<
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar }
export { ScrollArea, ScrollBar };
+9 -9
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
@@ -9,7 +9,7 @@ const Separator = React.forwardRef<
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
@@ -18,12 +18,12 @@ const Separator = React.forwardRef<
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
className,
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };
+2 -1
View File
@@ -93,7 +93,8 @@ const sheetVariants = cva(
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
extends
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
+3 -3
View File
@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Skeleton({
className,
@@ -9,7 +9,7 @@ function Skeleton({
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };
+1 -1
View File
@@ -9,7 +9,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-selected data-[state=unchecked]:bg-input",
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors data-[state=checked]:bg-selected data-[state=unchecked]:bg-input focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+23 -23
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
@@ -13,16 +13,16 @@ const Table = React.forwardRef<
{...props}
/>
</div>
))
Table.displayName = "Table"
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
@@ -33,8 +33,8 @@ const TableBody = React.forwardRef<
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
@@ -44,12 +44,12 @@ const TableFooter = React.forwardRef<
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
className,
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
@@ -58,13 +58,13 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
"border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50",
className,
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
@@ -74,12 +74,12 @@ const TableHead = React.forwardRef<
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
className,
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
@@ -90,8 +90,8 @@ const TableCell = React.forwardRef<
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
@@ -102,8 +102,8 @@ const TableCaption = React.forwardRef<
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
));
TableCaption.displayName = "TableCaption";
export {
Table,
@@ -114,4 +114,4 @@ export {
TableRow,
TableCell,
TableCaption,
}
};
+15 -15
View File
@@ -1,9 +1,9 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
@@ -13,12 +13,12 @@ const TabsList = React.forwardRef<
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
className,
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
@@ -27,13 +27,13 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
@@ -43,11 +43,11 @@ const TabsContent = React.forwardRef<
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
className,
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };
+2 -8
View File
@@ -1,13 +1,7 @@
import { cn } from "@/lib/utils";
type TextElements =
| "p"
| "blockquote"
| "code"
| "lead"
| "large"
| "small"
| "muted";
"p" | "blockquote" | "code" | "lead" | "large" | "small" | "muted";
const Text = ({
children,
@@ -36,7 +30,7 @@ const Text = ({
<code
className={cn(
"relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
className
className,
)}
>
{children}
+9 -10
View File
@@ -1,9 +1,8 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
@@ -11,14 +10,14 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
);
},
);
Textarea.displayName = "Textarea";
export { Textarea }
export { Textarea };
+14 -14
View File
@@ -1,16 +1,16 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { VariantProps } from "class-variance-authority"
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
@@ -26,16 +26,16 @@ const ToggleGroup = React.forwardRef<
{children}
</ToggleGroupContext>
</ToggleGroupPrimitive.Root>
))
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
@@ -45,15 +45,15 @@ const ToggleGroupItem = React.forwardRef<
variant: context.variant || variant,
size: context.size || size,
}),
className
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem }
export { ToggleGroup, ToggleGroupItem };
+9 -9
View File
@@ -1,8 +1,8 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
@@ -23,8 +23,8 @@ const toggleVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
@@ -36,8 +36,8 @@ const Toggle = React.forwardRef<
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
));
Toggle.displayName = TogglePrimitive.Root.displayName
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants }
export { Toggle, toggleVariants };
+10 -10
View File
@@ -1,13 +1,13 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
@@ -18,11 +18,11 @@ const TooltipContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
className,
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+25
View File
@@ -0,0 +1,25 @@
import { createContext } from "react";
export interface AuthState {
user: { username: string; role: string | null } | null;
allowedCameras: string[];
isLoading: boolean;
isAuthenticated: boolean; // true if auth is required
}
interface AuthContextType {
auth: AuthState;
login: (user: AuthState["user"]) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextType>({
auth: {
user: null,
allowedCameras: [],
isLoading: true,
isAuthenticated: false,
},
login: () => {},
logout: () => {},
});
@@ -1,30 +1,7 @@
import axios from "axios";
import { createContext, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import useSWR from "swr";
interface AuthState {
user: { username: string; role: string | null } | null;
allowedCameras: string[];
isLoading: boolean;
isAuthenticated: boolean; // true if auth is required
}
interface AuthContextType {
auth: AuthState;
login: (user: AuthState["user"]) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextType>({
auth: {
user: null,
allowedCameras: [],
isLoading: true,
isAuthenticated: false,
},
login: () => {},
logout: () => {},
});
import { AuthContext, AuthState } from "./auth-context";
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [auth, setAuth] = useState<AuthState>({
+1 -1
View File
@@ -6,7 +6,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { StatusBarMessagesProvider } from "@/context/statusbar-provider";
import { LanguageProvider } from "./language-provider";
import { StreamingSettingsProvider } from "./streaming-settings-provider";
import { AuthProvider } from "./auth-context";
import { AuthProvider } from "./auth-provider";
type TProvidersProps = {
children: ReactNode;
+28
View File
@@ -0,0 +1,28 @@
import { createContext } from "react";
export type StatusMessage = {
id: string;
text: string;
color?: string;
link?: string;
};
export type StatusMessagesState = {
[key: string]: StatusMessage[];
};
type StatusBarMessagesContextValue = {
messages: StatusMessagesState;
addMessage: (
key: string,
message: string,
color?: string,
messageId?: string,
link?: string,
) => string | undefined;
removeMessage: (key: string, messageId: string) => void;
clearMessages: (key: string) => void;
};
export const StatusBarMessagesContext =
createContext<StatusBarMessagesContextValue | null>(null);
+4 -33
View File
@@ -1,42 +1,13 @@
import { useState, ReactNode, useCallback, useMemo } from "react";
import {
createContext,
useState,
ReactNode,
useCallback,
useMemo,
} from "react";
export type StatusMessage = {
id: string;
text: string;
color?: string;
link?: string;
};
export type StatusMessagesState = {
[key: string]: StatusMessage[];
};
StatusBarMessagesContext,
StatusMessagesState,
} from "@/context/statusbar-context";
type StatusBarMessagesProviderProps = {
children: ReactNode;
};
type StatusBarMessagesContextValue = {
messages: StatusMessagesState;
addMessage: (
key: string,
message: string,
color?: string,
messageId?: string,
link?: string,
) => string | undefined;
removeMessage: (key: string, messageId: string) => void;
clearMessages: (key: string) => void;
};
export const StatusBarMessagesContext =
createContext<StatusBarMessagesContextValue | null>(null);
export function StatusBarMessagesProvider({
children,
}: StatusBarMessagesProviderProps) {
+78 -18
View File
@@ -4,11 +4,42 @@ import useSWR from "swr";
import { LivePlayerMode } from "@/types/live";
import useDeferredStreamMetadata from "./use-deferred-stream-metadata";
import { detectCameraAudioFeatures } from "@/utils/cameraUtil";
import {
evaluateStreamWebRTCAvailability,
useWebRTCGloballyAvailable,
} from "./use-webrtc-availability";
// Shared by the initial computation and the context-menu "Reset" so the two
// can't diverge.
function resolveLiveMode(
isRestreamed: boolean,
mseSupported: boolean,
webRTCAvailable: boolean,
requested: LivePlayerMode | undefined,
): LivePlayerMode {
let mode: LivePlayerMode;
if (!mseSupported) {
mode = isRestreamed && webRTCAvailable ? "webrtc" : "jsmpeg";
} else {
mode = isRestreamed ? "mse" : "jsmpeg";
}
if (requested === "jsmpeg") {
mode = "jsmpeg";
} else if (requested === "webrtc" && isRestreamed && webRTCAvailable) {
mode = "webrtc";
} else if (requested === "mse" && mseSupported && isRestreamed) {
mode = "mse";
}
return mode;
}
export default function useCameraLiveMode(
cameras: CameraConfig[],
windowVisible: boolean,
activeStreams?: { [cameraName: string]: string },
preferredModes?: { [cameraName: string]: LivePlayerMode | undefined },
) {
const { data: config } = useSWR<FrigateConfig>("config");
@@ -46,6 +77,25 @@ export default function useCameraLiveMode(
// Fetch stream metadata with deferred loading (doesn't block initial render)
const streamMetadata = useDeferredStreamMetadata(restreamedStreamNames);
const { globallyAvailable, globalReason } = useWebRTCGloballyAvailable();
// "checking" counts as usable because the probe re-enters it on every mount,
// and treating it as unavailable would downgrade a saved WebRTC choice.
const webRTCUsableStates = useMemo(() => {
const states: { [cameraName: string]: boolean } = {};
cameras.forEach((camera) => {
const streamName =
activeStreams?.[camera.name] ?? Object.values(camera.live.streams)[0];
const { available, reason } = evaluateStreamWebRTCAvailability({
globallyAvailable,
globalReason,
metadata: streamMetadata[streamName],
});
states[camera.name] = available || reason === "checking";
});
return states;
}, [cameras, activeStreams, globallyAvailable, globalReason, streamMetadata]);
// Compute live mode states
const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode;
@@ -81,11 +131,14 @@ export default function useCameraLiveMode(
newIsRestreamedStates[camera.name] = isRestreamed ?? false;
if (!mseSupported) {
newPreferredLiveModes[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
} else {
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
}
// Auto-selected default, overridden by the user's per-camera choice when viable
// Runtime fallback (player errors) still adjusts the mode on top of this base
newPreferredLiveModes[camera.name] = resolveLiveMode(
!!isRestreamed,
mseSupported,
webRTCUsableStates[camera.name] ?? false,
preferredModes?.[camera.name],
);
// Check each stream for audio support
if (isRestreamed) {
@@ -108,7 +161,15 @@ export default function useCameraLiveMode(
setPreferredLiveModes(newPreferredLiveModes);
setIsRestreamedStates(newIsRestreamedStates);
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
}, [activeStreams, cameras, config, windowVisible, streamMetadata]);
}, [
activeStreams,
cameras,
config,
windowVisible,
streamMetadata,
webRTCUsableStates,
preferredModes,
]);
const resetPreferredLiveMode = useCallback(
(cameraName: string) => {
@@ -124,19 +185,17 @@ export default function useCameraLiveMode(
config &&
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (!mseSupported) {
newModes[cameraName] = isRestreamed ? "webrtc" : "jsmpeg";
} else {
newModes[cameraName] = isRestreamed ? "mse" : "jsmpeg";
}
return newModes;
});
setPreferredLiveModes((prevModes) => ({
...prevModes,
[cameraName]: resolveLiveMode(
!!isRestreamed,
mseSupported,
webRTCUsableStates[cameraName] ?? false,
preferredModes?.[cameraName],
),
}));
},
[activeStreams, cameras, config],
[activeStreams, cameras, config, webRTCUsableStates, preferredModes],
);
return {
@@ -146,5 +205,6 @@ export default function useCameraLiveMode(
isRestreamedStates,
supportsAudioOutputStates,
streamMetadata,
webRTCUsableStates,
};
}
+2 -4
View File
@@ -123,8 +123,7 @@ function getDefaultFilter(
if (!schema) return undefined;
const sectionSchema = extractSectionSchema(schema, sectionPath, "camera");
const filtersSchema = sectionSchema?.properties?.filters as
| RJSFSchema
| undefined;
RJSFSchema | undefined;
if (!filtersSchema) return undefined;
// An optional map (`dict[str, X] | None`, as `audio.filters` is declared)
@@ -965,8 +964,7 @@ export function useProfileSectionDeltas(
const profileSection = (
cameraConfig.profiles?.[profileName] as
| Record<string, unknown>
| undefined
Record<string, unknown> | undefined
)?.[sectionPath];
if (profileSection == null) return [];

Some files were not shown because too many files have changed in this diff Show More