Compare commits

...

64 Commits

Author SHA1 Message Date
Chris King
005911d6a3 Add web only amd64 build and push target to Makefile 2025-01-21 13:03:22 -08:00
Chris King
088ff992f8 Fix webonly build definitions
Fix typo in arm64 docker/webonly/Dockerfile -> docker/main/Dockerfile
2025-01-21 10:56:29 -08:00
Chris King
e36dc576d3 Add webonly build and push options to Makefile
Change container repo to private Gitea
Add webonly build Dockerfile
Add .node-version for fnm
Do not route settings, config, or logs to non admin users
Do not show settings, system logs, system restart or config editor links to non admin users
Add list of admin usernames to user.ts
2025-01-21 10:51:54 -08:00
Josh Hawkins
f4f3cfa911 Don't allow periods in zone or camera group names (#13400) 2024-08-28 06:26:50 -06:00
Josh Hawkins
ca0f6e4c0a Add portal the live player tooltip (#13389) 2024-08-27 19:14:22 -06:00
Marc Altmann
a7ccabd8f1 update go2rtc version in reference config (#13367) 2024-08-26 15:17:24 -06:00
Nicolas Mowen
453a8d794e Add tooltip for icons in review event list (#13334) 2024-08-25 07:57:10 -05:00
Blake Blackshear
ce79898cae fix default build (#13321) 2024-08-24 07:44:15 -05:00
Blake Blackshear
bf90daae2b update actions for release (#13318) 2024-08-24 07:25:24 -05:00
Josh Hawkins
fdb5d53960 Update discussion templates (#13303)
* Update discussion templates

* camera support go2rtc
2024-08-23 18:05:14 -05:00
Nicolas Mowen
2dc5a7f767 Fix delayed preview not showing (#13295) 2024-08-23 09:51:59 -05:00
Josh Hawkins
65ca3c8fa3 Fix discussion templates (#13292)
* Fix yaml spacing for discussion templates

* Remove browser question from detectors
2024-08-23 07:58:39 -05:00
Josh Hawkins
ff34af2c1f Update discussion templates (#13291)
* Revamp support discussion templates

* move text to description

* remove duplicate logs box

* ffprobe on camera support

* longer description on config support
2024-08-23 06:44:31 -06:00
Nicolas Mowen
e01b6ee76b Fix case where user's cgroup says it has 0 cpu cores (#13271) 2024-08-22 08:06:26 -05:00
Nicolas Mowen
1c7ee5f4e4 UI fixes (#13246)
* Fix bad data in stats

* Add support for changes dialog when leaving without saving config editor

* Fix scrolling into view
2024-08-21 08:19:07 -06:00
Nicolas Mowen
d96f76c27f Ensure only enabled birdseye cameras are considered active (#13194)
* Ensure only enabled birdseye cameras are considered active

* Cleanup
2024-08-19 16:01:48 -05:00
Nicolas Mowen
1da934e63c Dynamically detect if full screen is supported (#13197) 2024-08-19 16:01:21 -05:00
Nicolas Mowen
38a8d34ba5 Preview fixes (#13193)
* Handle case where preview was saved late

* fix timing
2024-08-19 10:45:55 -06:00
Josh Hawkins
8e31244fb3 Adjust MSE player playback rate logic (#13164)
* Fix MSE playback rate logic

* don't adjust playback rate if we just started streaming

* memoize onprogress
2024-08-18 12:13:21 -06:00
Nicolas Mowen
3a124dbb84 Fix plus view resetting (#13160) 2024-08-18 07:41:10 -06:00
Josh Hawkins
8c23ede683 Live player fixes (#13143)
* Jump to live when exceeding buffer time threshold in MSE player

* clean up

* Try adjusting playback rate instead of jumping to live

* clean up

* fallback to webrtc if enabled before jsmpeg

* baseline

* clean up

* remove comments

* adaptive playback rate and intelligent switching improvements

* increase logging and reset live mode after camera is no longer active on dashboard only

* jump to live on safari/iOS

* clean up

* clean up

* refactor camera live mode hook

* remove key listener

* resolve conflicts
2024-08-17 12:16:48 -06:00
Josh Hawkins
4133e454c4 Remove dashboard keyboard listener (#13102) 2024-08-15 16:13:11 -05:00
Josh Hawkins
4dce8ff60a Add shortcut key "r" to mark selected items as reviewed (#13087)
* Add shortcut key "r" to mark selected items as reviewed

* unselect after keypress
2024-08-15 09:51:44 -05:00
Nicolas Mowen
2e724291db Catch case where github sends bad json data (#13077) 2024-08-14 20:41:41 -05:00
Nicolas Mowen
f6b61c26ae Rename bug report (#13039) 2024-08-13 14:26:01 -05:00
Nicolas Mowen
1b876bf8d3 UI fixes (#13030)
* Fix difficulty overwriting export name

* Fix NaN for score selector
2024-08-13 10:12:06 -05:00
Nicolas Mowen
b0d42ea116 Fix last hour preview (#13027) 2024-08-13 08:23:46 -06:00
Nicolas Mowen
05bc3839cc Reset recordings when changing the date (#13009) 2024-08-12 15:12:49 -06:00
Nicolas Mowen
281482927a Recordings Fixes (#13005)
* If recordings don't exist mark as no recordings

* Fix reloading recordings failing

* Fix mark items not clearing selected

* Cleanup

* Default to last full hour when error occurs

* Remove check

* Cleanup

* Handle empty recordings list case

* Ensure that the start time is within the time range

* Catch other reset cases
2024-08-12 14:30:16 -06:00
Nicolas Mowen
132a712341 Hide record switch when disabled (#12997) 2024-08-12 08:21:21 -05:00
Nicolas Mowen
13d121f443 Catch case where recording starts right at end of request (#12956) 2024-08-11 08:32:17 -05:00
Josh Hawkins
67ba3dbd8b Add pan/pinch/zoom capability on plus snapshots (#12953) 2024-08-11 07:15:04 -06:00
Nicolas Mowen
4afa7bf4e1 Catch case where user tries to end definite manual event (#12951)
* Catch case where user tries to end definite manual event

* Formatting
2024-08-11 07:32:39 -05:00
Josh Hawkins
77bf710299 Add confirmation dialog before deleting review items (#12950) 2024-08-11 06:25:09 -06:00
Stavros Kois
9b96211faf add shortcut and query for fullscreen in live view (#12924)
* add shortcut and query for live view

* Update web/src/views/live/LiveDashboardView.tsx

* Update web/src/views/live/LiveDashboardView.tsx

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>

* Apply suggestions from code review

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>

* Update LiveDashboardView.tsx

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2024-08-10 10:25:13 -06:00
Nicolas Mowen
99e03576bf Remove user args from http jpeg (#12909) 2024-08-09 16:22:24 -06:00
Nicolas Mowen
78d67484e1 Web deps (#12908)
* Update web compnent deps

* Update other web deps
2024-08-09 16:12:07 -06:00
Nicolas Mowen
e9e86cc5af Fix use experimental migrator (#12906) 2024-08-09 16:59:55 -05:00
Nicolas Mowen
70618e93b7 Add button to mark review item as reviewed in filmstrip (#12878)
* Add button to mark review item as reviewd in filmstrip

* Add tooltip
2024-08-09 08:29:35 -05:00
Soren L. Hansen
c84511de16 Fix auth when serving Frigate at a subpath (#12815)
Ensure axios.defaults.baseURL is set when accessing login form.

Drop `/api` prefix in login form's `axios.post` call, since `/api` is
part of the baseURL.

Redirect to subpath on succesful authentication.

Prepend subpath to default logout url.

Fixes #12814
2024-08-09 07:26:26 -06:00
Josh Hawkins
6d9590b4ec Persist live view muted/unmuted for session only (#12727)
* Persist live view muted/unmuted for session only

* consistent naming
2024-08-09 06:46:39 -06:00
Josh Hawkins
33e04fe61f Add right click to delete points in desktop mask/zone editor (#12744) 2024-08-09 06:46:18 -06:00
Josh Hawkins
9f43d10ba7 Ensure review card icon color for event view is visible in light mode (#12812) 2024-08-08 07:54:13 -06:00
Marc Altmann
57503cc318 fix default model for rknn detector (#12807) 2024-08-08 07:54:13 -06:00
Nicolas Mowen
e563692fa2 Add camera name to audio debug line (#12799)
* Add camera name to audio debug line

* Formatting
2024-08-08 07:54:13 -06:00
Nicolas Mowen
9c2974438d Handle case where user stops scrubbing but remains hovering (#12794)
* Handle case where user stops scrubbing but remains hovering

* Add type
2024-08-08 07:54:13 -06:00
Josh Hawkins
54e1bd9eeb Ensure review cameras are sorted by config ui order if specified (#12789) 2024-08-08 07:54:13 -06:00
Nicolas Mowen
8212b66ee0 Use camera status to get state of camera config (#12787)
* Use camera status to get state of camera config

* Fix spelling
2024-08-08 07:54:13 -06:00
Nicolas Mowen
43d2986208 Handle case where sub label was null (#12785) 2024-08-08 07:54:13 -06:00
Nicolas Mowen
f8f7b74792 Update version 2024-08-08 07:54:13 -06:00
Nicolas Mowen
5069072a84 Fix iOS export buttons (#12755)
* Fix iOS export buttons

* Use layering instead of z index
2024-08-08 07:54:13 -06:00
Josh Hawkins
93b81756c6 Only use dense property on phones for motion review timeline (#12768) 2024-08-08 07:54:13 -06:00
Josh Hawkins
4a867ddd56 Use radix css var to limit desktop menu height (#12743) 2024-08-08 07:54:13 -06:00
Josh Hawkins
a347cb5a42 Fix large tablet recording view layout (#12753) 2024-08-08 07:54:13 -06:00
Blake Blackshear
80e8930e73 always release from dev builds 2024-08-08 08:25:19 -05:00
Nicolas Mowen
2637541c6c Installation and getting started docs improvements (#12395)
* Add tip banner for quick links

* Add link to getting started guide from installation

* Remove dummy config

* Move tip

* Clarify installation docs

Co-authored-by: Blake Blackshear <blake@frigate.video>

* Update docs/docs/guides/getting_started.md

Co-authored-by: Blake Blackshear <blake@frigate.video>

---------

Co-authored-by: Blake Blackshear <blake@frigate.video>
2024-08-03 08:20:14 -05:00
Jake Angerman
da913d8d31 Add example of single pcie coral (#12446)
* Update object_detectors.md

* Update docs/docs/configuration/object_detectors.md

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2024-08-03 06:38:40 -06:00
Nicolas Mowen
88d4b694f8 Fix tall videos from covering height in export page (#12725)
* Fix tall videos from covering height in export page

* Handle mobile landscape
2024-08-02 07:06:15 -06:00
Nicolas Mowen
b28cc45510 Update docs (#12714) 2024-08-01 17:27:15 -06:00
Nicolas Mowen
c0b23ca938 Remove mention of older yolox model (#12711) 2024-08-01 14:33:06 -06:00
Josh Hawkins
8e7b83d2f1 Display messages when no events exist (#12694)
* Display message in desktop events list when no events exist

* Add message for when no events are found on plus view

* validating check

* activity indicator check

* clarify error message
2024-07-31 14:08:07 -06:00
Nicolas Mowen
599dd7eecb Build libusb for coral compatibility (#12681) 2024-07-30 16:32:32 -06:00
Nicolas Mowen
84348350fe apply iOS fix to safari (#12663) 2024-07-29 11:34:45 -05:00
Nicolas Mowen
7d03d99852 Show skeleton when live filmstrip items are loading (#12660) 2024-07-29 07:52:22 -05:00
78 changed files with 2095 additions and 1054 deletions

View File

@@ -1,83 +0,0 @@
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: textarea
id: description
attributes:
label: Describe the problem you are having
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Visible on the System page in the Web UI
validations:
required: true
- type: textarea
id: config
attributes:
label: Frigate config file
description: This will be automatically formatted into code, so no need for backticks.
render: yaml
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- HassOS
- Debian
- Other Linux
- Proxmox
- UNRAID
- Windows
- Other
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Install method
options:
- HassOS Addon
- Docker Compose
- Docker CLI
validations:
required: true
- type: dropdown
id: network
attributes:
label: Network connection
options:
- Wired
- Wireless
- Mixed
validations:
required: true
- type: input
id: camera
attributes:
label: Camera make and model
description: Dahua, hikvision, amcrest, reolink, etc and model number
validations:
required: true
- type: textarea
id: other
attributes:
label: Any other information that may be helpful

View File

@@ -1,6 +1,16 @@
title: "[Camera Support]: " title: "[Camera Support]: "
labels: ["support", "triage"] labels: ["support", "triage"]
body: body:
- type: markdown
attributes:
value: |
Use this form for support or questions for an issue with your cameras.
Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
@@ -11,9 +21,15 @@ body:
id: version id: version
attributes: attributes:
label: Version label: Version
description: Visible on the System page in the Web UI description: Visible on the System page in the Web UI. Please include the full version including the build identifier (eg. 0.14.0-ea36ds1)
validations: validations:
required: true required: true
- type: input
attributes:
label: What browser(s) are you using?
placeholder: Google Chrome 88.0.4324.150
description: >
Provide the full name and don't forget to add the version!
- type: textarea - type: textarea
id: config id: config
attributes: attributes:
@@ -23,10 +39,18 @@ body:
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: logs id: frigatelogs
attributes: attributes:
label: Relevant log output label: Relevant Frigate log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. description: Please copy and paste any relevant Frigate log output. Include logs before and after your exact error when possible. This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: true
- type: textarea
id: go2rtclogs
attributes:
label: Relevant go2rtc log output
description: Please copy and paste any relevant go2rtc log output. Include logs before and after your exact error when possible. Logs can be viewed via the Frigate UI, Docker, or the go2rtc dashboard. This will be automatically formatted into code, so no need for backticks.
render: shell render: shell
validations: validations:
required: true required: true
@@ -34,7 +58,7 @@ body:
id: ffprobe id: ffprobe
attributes: attributes:
label: FFprobe output from your camera label: FFprobe output from your camera
description: Run `ffprobe <camera_url>` and provide output below description: Run `ffprobe <camera_url>` from within the Frigate container if possible, and provide output below
render: shell render: shell
validations: validations:
required: true required: true
@@ -78,7 +102,7 @@ body:
- TensorRT - TensorRT
- RKNN - RKNN
- Other - Other
- CPU (no coral) - CPU (no Coral)
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
@@ -98,6 +122,13 @@ body:
description: Dahua, hikvision, amcrest, reolink, etc and model number description: Dahua, hikvision, amcrest, reolink, etc and model number
validations: validations:
required: true required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots of the Frigate UI's System metrics pages
description: Drag and drop for images is possible in this field. Please post screenshots of at least General and Cameras tabs.
validations:
required: true
- type: textarea - type: textarea
id: other id: other
attributes: attributes:

View File

@@ -1,6 +1,16 @@
title: "[Config Support]: " title: "[Config Support]: "
labels: ["support", "triage"] labels: ["support", "triage"]
body: body:
- type: markdown
attributes:
value: |
Use this form for support or questions related to Frigate's configuration and config file.
Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
@@ -11,7 +21,7 @@ body:
id: version id: version
attributes: attributes:
label: Version label: Version
description: Visible on the System page in the Web UI description: Visible on the System page in the Web UI. Please include the full version including the build identifier (eg. 0.14.0-ea36ds1)
validations: validations:
required: true required: true
- type: textarea - type: textarea
@@ -23,10 +33,18 @@ body:
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: logs id: frigatelogs
attributes: attributes:
label: Relevant log output label: Relevant Frigate log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. description: Please copy and paste any relevant Frigate log output. Include logs before and after your exact error when possible. This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: true
- type: textarea
id: go2rtclogs
attributes:
label: Relevant go2rtc log output
description: Please copy and paste any relevant go2rtc log output. Include logs before and after your exact error when possible. This will be automatically formatted into code, so no need for backticks.
render: shell render: shell
validations: validations:
required: true required: true
@@ -73,6 +91,11 @@ body:
- CPU (no coral) - CPU (no coral)
validations: validations:
required: true required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots of the Frigate UI's System metrics pages
description: Drag and drop or simple cut/paste is possible in this field
- type: textarea - type: textarea
id: other id: other
attributes: attributes:

View File

@@ -1,6 +1,16 @@
title: "[Detector Support]: " title: "[Detector Support]: "
labels: ["support", "triage"] labels: ["support", "triage"]
body: body:
- type: markdown
attributes:
value: |
Use this form for support or questions related to Frigate's object detectors.
Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
@@ -11,7 +21,7 @@ body:
id: version id: version
attributes: attributes:
label: Version label: Version
description: Visible on the System page in the Web UI description: Visible on the System page in the Web UI. Please include the full version including the build identifier (eg. 0.14.0-ea36ds1)
validations: validations:
required: true required: true
- type: textarea - type: textarea
@@ -31,10 +41,18 @@ body:
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: logs id: frigatelogs
attributes: attributes:
label: Relevant log output label: Relevant Frigate log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. description: Please copy and paste any relevant Frigate log output. Include logs before and after your exact error when possible. This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: true
- type: textarea
id: go2rtclogs
attributes:
label: Relevant go2rtc log output
description: Please copy and paste any relevant go2rtc log output. Include logs before and after your exact error when possible. Logs can be viewed via the Frigate UI, Docker, or the go2rtc dashboard. This will be automatically formatted into code, so no need for backticks.
render: shell render: shell
validations: validations:
required: true required: true
@@ -75,6 +93,13 @@ body:
- CPU (no coral) - CPU (no coral)
validations: validations:
required: true required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots of the Frigate UI's System metrics pages
description: Drag and drop for images is possible in this field. Please post screenshots of at least General and Cameras tabs.
validations:
required: true
- type: textarea - type: textarea
id: other id: other
attributes: attributes:

View File

@@ -1,6 +1,16 @@
title: "[Support]: " title: "[Support]: "
labels: ["support", "triage"] labels: ["support", "triage"]
body: body:
- type: markdown
attributes:
value: |
Use this form for support for issues that don't fall into any specific category.
Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
@@ -11,9 +21,15 @@ body:
id: version id: version
attributes: attributes:
label: Version label: Version
description: Visible on the System page in the Web UI description: Visible on the System page in the Web UI. Please include the full version including the build identifier (eg. 0.14.0-ea36ds1)
validations: validations:
required: true required: true
- type: input
attributes:
label: What browser(s) are you using?
placeholder: Google Chrome 88.0.4324.150
description: >
Provide the full name and don't forget to add the version!
- type: textarea - type: textarea
id: config id: config
attributes: attributes:
@@ -23,10 +39,18 @@ body:
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: logs id: frigatelogs
attributes: attributes:
label: Relevant log output label: Relevant Frigate log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. description: Please copy and paste any relevant Frigate log output. Include logs before and after your exact error when possible. This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: true
- type: textarea
id: go2rtclogs
attributes:
label: Relevant go2rtc log output
description: Please copy and paste any relevant go2rtc log output. Include logs before and after your exact error when possible. Logs can be viewed via the Frigate UI, Docker, or the go2rtc dashboard. This will be automatically formatted into code, so no need for backticks.
render: shell render: shell
validations: validations:
required: true required: true
@@ -34,7 +58,7 @@ body:
id: ffprobe id: ffprobe
attributes: attributes:
label: FFprobe output from your camera label: FFprobe output from your camera
description: Run `ffprobe <camera_url>` and provide output below description: Run `ffprobe <camera_url>` from within the Frigate container if possible, and provide output below
render: shell render: shell
validations: validations:
required: true required: true
@@ -98,6 +122,11 @@ body:
description: Dahua, hikvision, amcrest, reolink, etc and model number description: Dahua, hikvision, amcrest, reolink, etc and model number
validations: validations:
required: true required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots of the Frigate UI's System metrics pages
description: Drag and drop for images is possible in this field
- type: textarea - type: textarea
id: other id: other
attributes: attributes:

View File

@@ -1,6 +1,16 @@
title: "[HW Accel Support]: " title: "[HW Accel Support]: "
labels: ["support", "triage"] labels: ["support", "triage"]
body: body:
- type: markdown
attributes:
value: |
Use this form to submit a support request for hardware acceleration issues.
Before submitting your support request, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community.
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
@@ -11,9 +21,15 @@ body:
id: version id: version
attributes: attributes:
label: Version label: Version
description: Visible on the System page in the Web UI description: Visible on the System page in the Web UI. Please include the full version including the build identifier (eg. 0.14.0-ea36ds1)
validations: validations:
required: true required: true
- type: input
attributes:
label: In which browser(s) are you experiencing the issue with?
placeholder: Google Chrome 88.0.4324.150
description: >
Provide the full name and don't forget to add the version!
- type: textarea - type: textarea
id: config id: config
attributes: attributes:
@@ -31,10 +47,18 @@ body:
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: logs id: frigatelogs
attributes: attributes:
label: Relevant log output label: Relevant Frigate log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. description: Please copy and paste any relevant Frigate log output. Include logs before and after your exact error when possible. This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: true
- type: textarea
id: go2rtclogs
attributes:
label: Relevant go2rtc log output
description: Please copy and paste any relevant go2rtc log output. Include logs before and after your exact error when possible. Logs can be viewed via the Frigate UI, Docker, or the go2rtc dashboard. This will be automatically formatted into code, so no need for backticks.
render: shell render: shell
validations: validations:
required: true required: true
@@ -42,7 +66,7 @@ body:
id: ffprobe id: ffprobe
attributes: attributes:
label: FFprobe output from your camera label: FFprobe output from your camera
description: Run `ffprobe <camera_url>` and provide output below description: Run `ffprobe <camera_url>` from within the Frigate container if possible, and provide output below
render: shell render: shell
validations: validations:
required: true required: true
@@ -87,6 +111,13 @@ body:
description: Dahua, hikvision, amcrest, reolink, etc and model number description: Dahua, hikvision, amcrest, reolink, etc and model number
validations: validations:
required: true required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots of the Frigate UI's System metrics pages
description: Drag and drop for images is possible in this field. Please post screenshots of at least General and Cameras tabs.
validations:
required: true
- type: textarea - type: textarea
id: other id: other
attributes: attributes:

View File

@@ -1,9 +1,21 @@
title: "[Question]: " title: "[Question]: "
labels: ["question"] labels: ["question"]
body: body:
- type: markdown
attributes:
value: |
Use this form for questions you have about Frigate.
Before submitting your question, please [search the discussions][discussions], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your question has already been answered by the community.
**If you are looking for support, start a new discussion and use a support category.**
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
label: "What is your question:" label: "What is your question?"
validations: validations:
required: true required: true

View File

@@ -0,0 +1,146 @@
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Use this form to submit a reproducible bug in Frigate or Frigate's UI.
Before submitting your bug report, please [search the discussions][discussions], look at recent open and closed [pull requests][prs], read the [official Frigate documentation][docs], and read the [Frigate FAQ][faq] pinned at the Discussion page to see if your bug has already been fixed by the developers or reported by the community.
**If you are unsure if your issue is actually a bug or not, please submit a support request first.**
[discussions]: https://www.github.com/blakeblackshear/frigate/discussions
[prs]: https://www.github.com/blakeblackshear/frigate/pulls
[docs]: https://docs.frigate.video
[faq]: https://github.com/blakeblackshear/frigate/discussions/12724
- type: checkboxes
attributes:
label: Checklist
description: Please verify that you've followed these steps
options:
- label: I have updated to the latest available Frigate version.
required: true
- label: I have cleared the cache of my browser.
required: true
- label: I have tried a different browser to see if it is related to my browser.
required: true
- label: I have tried reproducing the issue in [incognito mode](https://www.computerworld.com/article/1719851/how-to-go-incognito-in-chrome-firefox-safari-and-edge.html) to rule out problems with any third party extensions or plugins I have installed.
- type: textarea
id: description
attributes:
label: Describe the problem you are having
description: Provide a clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: |
Please tell us exactly how to reproduce your issue.
Provide clear and concise step by step instructions and add code snippets if needed.
value: |
1.
2.
3.
...
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Visible on the System page in the Web UI. Please include the full version including the build identifier (eg. 0.14.0-ea36ds1)
validations:
required: true
- type: input
attributes:
label: In which browser(s) are you experiencing the issue with?
placeholder: Google Chrome 88.0.4324.150
description: >
Provide the full name and don't forget to add the version!
- type: textarea
id: config
attributes:
label: Frigate config file
description: This will be automatically formatted into code, so no need for backticks.
render: yaml
validations:
required: true
- type: textarea
id: docker
attributes:
label: docker-compose file or Docker CLI command
description: This will be automatically formatted into code, so no need for backticks.
render: yaml
validations:
required: true
- type: textarea
id: frigatelogs
attributes:
label: Relevant Frigate log output
description: Please copy and paste any relevant Frigate log output. Include logs before and after your exact error when possible. This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: true
- type: textarea
id: go2rtclogs
attributes:
label: Relevant go2rtc log output
description: Please copy and paste any relevant go2rtc log output. Include logs before and after your exact error when possible. Logs can be viewed via the Frigate UI, Docker, or the go2rtc dashboard. This will be automatically formatted into code, so no need for backticks.
render: shell
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options:
- HassOS
- Debian
- Other Linux
- Proxmox
- UNRAID
- Windows
- Other
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Install method
options:
- HassOS Addon
- Docker Compose
- Docker CLI
validations:
required: true
- type: dropdown
id: network
attributes:
label: Network connection
options:
- Wired
- Wireless
- Mixed
validations:
required: true
- type: input
id: camera
attributes:
label: Camera make and model
description: Dahua, hikvision, amcrest, reolink, etc and model number
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots of the Frigate UI's System metrics pages
description: Drag and drop for images is possible in this field. Please post screenshots of all tabs.
validations:
required: true
- type: textarea
id: other
attributes:
label: Any other information that may be helpful

View File

@@ -5,7 +5,7 @@ inputs:
required: true required: true
outputs: outputs:
image-name: image-name:
value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ github.ref_name }}-${{ steps.create-short-sha.outputs.SHORT_SHA }} value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ steps.create-short-sha.outputs.SHORT_SHA }}
cache-name: cache-name:
value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:cache value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:cache
runs: runs:

View File

@@ -229,7 +229,7 @@ jobs:
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- uses: int128/docker-manifest-create-action@v2 - uses: int128/docker-manifest-create-action@v2
with: with:
tags: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ github.ref_name }}-${{ env.SHORT_SHA }} tags: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}
sources: | sources: |
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ github.ref_name }}-${{ env.SHORT_SHA }}-amd64 ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-amd64
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ github.ref_name }}-${{ env.SHORT_SHA }}-rpi ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-rpi

View File

@@ -23,10 +23,10 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Create tag variables - name: Create tag variables
run: | run: |
BRANCH=$([[ "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "master" || echo "dev") BUILD_TYPE=$([[ "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "stable" || echo "beta")
echo "BRANCH=${BRANCH}" >> $GITHUB_ENV echo "BUILD_TYPE=${BUILD_TYPE}" >> $GITHUB_ENV
echo "BASE=ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}" >> $GITHUB_ENV echo "BASE=ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}" >> $GITHUB_ENV
echo "BUILD_TAG=${BRANCH}-${GITHUB_SHA::7}" >> $GITHUB_ENV echo "BUILD_TAG=${GITHUB_SHA::7}" >> $GITHUB_ENV
echo "CLEAN_VERSION=$(echo ${GITHUB_REF##*/} | tr '[:upper:]' '[:lower:]' | sed 's/^[v]//')" >> $GITHUB_ENV echo "CLEAN_VERSION=$(echo ${GITHUB_REF##*/} | tr '[:upper:]' '[:lower:]' | sed 's/^[v]//')" >> $GITHUB_ENV
- name: Tag and push the main image - name: Tag and push the main image
run: | run: |
@@ -39,7 +39,7 @@ jobs:
done done
# stable tag # stable tag
if [[ "${BRANCH}" == "master" ]]; then if [[ "${BUILD_TYPE}" == "stable" ]]; then
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${STABLE_TAG} docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${STABLE_TAG}
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${STABLE_TAG}-${variant} docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${STABLE_TAG}-${variant}

View File

@@ -25,17 +25,17 @@ jobs:
- name: Print outputs - name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }} run: echo ${{ join(steps.stale.outputs.*, ',') }}
clean_ghcr: # clean_ghcr:
name: Delete outdated dev container images # name: Delete outdated dev container images
runs-on: ubuntu-latest # runs-on: ubuntu-latest
steps: # steps:
- name: Delete old images # - name: Delete old images
uses: snok/container-retention-policy@v2 # uses: snok/container-retention-policy@v2
with: # with:
image-names: dev-* # image-names: dev-*
cut-off: 60 days ago UTC # cut-off: 60 days ago UTC
keep-at-least: 5 # keep-at-least: 5
account-type: personal # account-type: personal
token: ${{ secrets.GITHUB_TOKEN }} # token: ${{ secrets.GITHUB_TOKEN }}
token-type: github-token # token-type: github-token

View File

@@ -1,8 +1,9 @@
default_target: local default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.14.0 VERSION = 0.14.1
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate #IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
IMAGE_REPO ?= gitea.tremendousturtle.tools/chris/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
CURRENT_UID := $(shell id -u) CURRENT_UID := $(shell id -u)
CURRENT_GID := $(shell id -g) CURRENT_GID := $(shell id -g)
@@ -23,15 +24,30 @@ local: version
amd64: amd64:
docker buildx build --platform linux/amd64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) --file docker/main/Dockerfile . docker buildx build --platform linux/amd64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) --file docker/main/Dockerfile .
amd64_web:
docker buildx build --platform linux/amd64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) --file docker/webonly/Dockerfile .
arm64: arm64:
docker buildx build --platform linux/arm64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) --file docker/main/Dockerfile . docker buildx build --platform linux/arm64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) --file docker/main/Dockerfile .
arm64_web:
docker buildx build --platform linux/arm64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) --file docker/webonly/Dockerfile .
build: version amd64 arm64 build: version amd64 arm64
docker buildx build --platform linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) --file docker/main/Dockerfile . docker buildx build --platform linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) --file docker/main/Dockerfile .
build_web: version amd64_web arm64_web
docker buildx build --platform linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):$(VERSION)-$(COMMIT_HASH) --file docker/webonly/Dockerfile .
push: push-boards push: push-boards
docker buildx build --push --platform linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH) --file docker/main/Dockerfile . docker buildx build --push --platform linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH) --file docker/main/Dockerfile .
push_web: push-boards
docker buildx build --push --platform linux/arm64/v8,linux/amd64 --target=frigate --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH) --file docker/webonly/Dockerfile .
push_web-amd64:
docker buildx build --push --platform linux/amd64 --target=frigate --tag $(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH) --file docker/webonly/Dockerfile .
run: local run: local
docker run --rm --publish=5000:5000 --volume=${PWD}/config:/config frigate:latest docker run --rm --publish=5000:5000 --volume=${PWD}/config:/config frigate:latest

View File

@@ -66,6 +66,40 @@ RUN --mount=type=bind,source=docker/main/build_ov_model.py,target=/build_ov_mode
&& tar -xvf ssdlite_mobilenet_v2_coco_2018_05_09.tar.gz \ && tar -xvf ssdlite_mobilenet_v2_coco_2018_05_09.tar.gz \
&& python3 /build_ov_model.py && python3 /build_ov_model.py
####
#
# Coral Compatibility
#
# Builds libusb without udev. Needed for synology and other devices with USB coral
####
# libUSB - No Udev
FROM wget as libusb-build
ARG TARGETARCH
ARG DEBIAN_FRONTEND
ENV CCACHE_DIR /root/.ccache
ENV CCACHE_MAXSIZE 2G
# Build libUSB without udev. Needed for Openvino NCS2 support
WORKDIR /opt
RUN apt-get update && apt-get install -y unzip build-essential automake libtool ccache pkg-config
RUN --mount=type=cache,target=/root/.ccache wget -q https://github.com/libusb/libusb/archive/v1.0.26.zip -O v1.0.26.zip && \
unzip v1.0.26.zip && cd libusb-1.0.26 && \
./bootstrap.sh && \
./configure CC='ccache gcc' CCX='ccache g++' --disable-udev --enable-shared && \
make -j $(nproc --all)
RUN apt-get update && \
apt-get install -y --no-install-recommends libusb-1.0-0-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /opt/libusb-1.0.26/libusb
RUN /bin/mkdir -p '/usr/local/lib' && \
/bin/bash ../libtool --mode=install /usr/bin/install -c libusb-1.0.la '/usr/local/lib' && \
/bin/mkdir -p '/usr/local/include/libusb-1.0' && \
/usr/bin/install -c -m 644 libusb.h '/usr/local/include/libusb-1.0' && \
/bin/mkdir -p '/usr/local/lib/pkgconfig' && \
cd /opt/libusb-1.0.26/ && \
/usr/bin/install -c -m 644 libusb-1.0.pc '/usr/local/lib/pkgconfig' && \
ldconfig
FROM wget AS models FROM wget AS models
# Get model and labels # Get model and labels
@@ -135,6 +169,7 @@ RUN pip3 wheel --wheel-dir=/wheels -r /requirements-wheels.txt
FROM scratch AS deps-rootfs FROM scratch AS deps-rootfs
COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/ COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
COPY --from=go2rtc /rootfs/ / COPY --from=go2rtc /rootfs/ /
COPY --from=libusb-build /usr/local/lib /usr/local/lib
COPY --from=tempio /rootfs/ / COPY --from=tempio /rootfs/ /
COPY --from=s6-overlay /rootfs/ / COPY --from=s6-overlay /rootfs/ /
COPY --from=models /rootfs/ / COPY --from=models /rootfs/ /
@@ -165,6 +200,8 @@ RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
COPY --from=deps-rootfs / / COPY --from=deps-rootfs / /
RUN ldconfig
EXPOSE 5000 EXPOSE 5000
EXPOSE 8554 EXPOSE 8554
EXPOSE 8555/tcp 8555/udp EXPOSE 8555/tcp 8555/udp

View File

@@ -38,7 +38,7 @@ function get_cpus() {
fi fi
local cpus local cpus
if [ -n "${quota}" ] && [ -n "${period}" ]; then if [ "${period}" != "0" ] && [ -n "${quota}" ] && [ -n "${period}" ]; then
cpus=$((quota / period)) cpus=$((quota / period))
if [ "$cpus" -eq 0 ]; then if [ "$cpus" -eq 0 ]; then
cpus=1 cpus=1

19
docker/webonly/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1.6
# Frigate web build
# This should be architecture agnostic, so speed up the build on multiarch by not using QEMU.
FROM --platform=$BUILDPLATFORM node:20 AS web-build
WORKDIR /work
COPY web/package.json web/package-lock.json ./
RUN npm install
COPY web/ ./
RUN npm run build \
&& mv dist/BASE_PATH/monacoeditorwork/* dist/assets/ \
&& rm -rf dist/BASE_PATH
FROM --platform=$BUILDPLATFORM ghcr.io/blakeblackshear/frigate:stable AS frigate
WORKDIR /opt/frigate/
RUN rm -rf web/ && mkdir web
COPY --from=web-build /work/dist/ web/

View File

@@ -80,6 +80,14 @@ model:
input_pixel_format: "bgr" input_pixel_format: "bgr"
``` ```
#### `labelmap`
:::warning
If the labelmap is customized then the labels used for alerts will need to be adjusted as well. See [alert labels](../configuration/review.md#restricting-alerts-to-specific-labels) for more info.
:::
The labelmap can be customized to your needs. A common reason to do this is to combine multiple object types that are easily confused when you don't need to be as granular such as car/truck. By default, truck is renamed to car because they are often confused. You cannot add new object types, but you can change the names of existing objects in the model. The labelmap can be customized to your needs. A common reason to do this is to combine multiple object types that are easily confused when you don't need to be as granular such as car/truck. By default, truck is renamed to car because they are often confused. You cannot add new object types, but you can change the names of existing objects in the model.
```yaml ```yaml

View File

@@ -111,6 +111,6 @@ camera_groups:
cameras: cameras:
- driveway_cam - driveway_cam
- garage_cam - garage_cam
icon: car icon: LuCar
order: 0 order: 0
``` ```

View File

@@ -81,6 +81,15 @@ detectors:
device: "" device: ""
``` ```
### Single PCIE/M.2 Coral
```yaml
detectors:
coral:
type: edgetpu
device: pci
```
### Multiple PCIE/M.2 Corals ### Multiple PCIE/M.2 Corals
```yaml ```yaml
@@ -136,23 +145,7 @@ model:
#### YOLOX #### YOLOX
This detector also supports YOLOX. Frigate does not come with any YOLOX models preloaded, so you will need to supply your own models. This detector has been verified to work with the [yolox_tiny](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/yolox-tiny) model from Intel's Open Model Zoo. You can follow [these instructions](https://github.com/openvinotoolkit/open_model_zoo/tree/master/models/public/yolox-tiny#download-a-model-and-convert-it-into-openvino-ir-format) to retrieve the OpenVINO-compatible `yolox_tiny` model. Make sure that the model input dimensions match the `width` and `height` parameters, and `model_type` is set accordingly. See [Full Configuration Reference](/configuration/reference.md) for a list of possible `model_type` options. Below is an example of how `yolox_tiny` can be used in Frigate: This detector also supports YOLOX. Frigate does not come with any YOLOX models preloaded, so you will need to supply your own models.
```yaml
detectors:
ov:
type: openvino
device: GPU
model:
width: 416
height: 416
input_tensor: nchw
input_pixel_format: bgr
model_type: yolox
path: /path/to/yolox_tiny.xml
labelmap_path: /path/to/coco_80cl.txt
```
#### YOLO-NAS #### YOLO-NAS

View File

@@ -466,7 +466,7 @@ snapshots:
quality: 70 quality: 70
# Optional: Restream configuration # Optional: Restream configuration
# Uses https://github.com/AlexxIT/go2rtc (v1.8.3) # Uses https://github.com/AlexxIT/go2rtc (v1.9.2)
go2rtc: go2rtc:
# Optional: jsmpeg stream configuration for WebUI # Optional: jsmpeg stream configuration for WebUI

View File

@@ -5,6 +5,12 @@ title: Installation
Frigate is a Docker container that can be run on any Docker host including as a [HassOS Addon](https://www.home-assistant.io/addons/). Note that a Home Assistant Addon is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant. Frigate is a Docker container that can be run on any Docker host including as a [HassOS Addon](https://www.home-assistant.io/addons/). Note that a Home Assistant Addon is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant.
:::tip
If you already have Frigate installed as a Home Assistant addon, check out the [getting started guide](../guides/getting_started#configuring-frigate) to configure Frigate.
:::
## Dependencies ## Dependencies
**MQTT broker (optional)** - An MQTT broker is optional with Frigate, but is required for the Home Assistant integration. If using Home Assistant, Frigate and Home Assistant must be connected to the same MQTT broker. **MQTT broker (optional)** - An MQTT broker is optional with Frigate, but is required for the Home Assistant integration. If using Home Assistant, Frigate and Home Assistant must be connected to the same MQTT broker.

View File

@@ -5,9 +5,17 @@ title: Getting started
# Getting Started # Getting Started
:::tip
If you already have an environment with Linux and Docker installed, you can continue to [Installing Frigate](#installing-frigate) below.
If you already have Frigate installed in Docker or as a Home Assistant addon, you can continue to [Configuring Frigate](#configuring-frigate) below.
:::
## Setting up hardware ## Setting up hardware
This section guides you through setting up a server with Debian Bookworm and Docker. If you already have an environment with Linux and Docker installed, you can continue to [Installing Frigate](#installing-frigate) below. This section guides you through setting up a server with Debian Bookworm and Docker.
### Install Debian 12 (Bookworm) ### Install Debian 12 (Bookworm)
@@ -77,20 +85,19 @@ This section shows how to create a minimal directory structure for a Docker inst
### Setup directories ### Setup directories
Frigate requires a valid config file to start. The following directory structure is the bare minimum to get started. Once Frigate is running, you can use the built-in config editor which supports config validation. Frigate will create a config file if one does not exist on the initial startup. The following directory structure is the bare minimum to get started. Once Frigate is running, you can use the built-in config editor which supports config validation.
``` ```
. .
├── docker-compose.yml ├── docker-compose.yml
├── config/ ├── config/
│ └── config.yml
└── storage/ └── storage/
``` ```
This will create the above structure: This will create the above structure:
```bash ```bash
mkdir storage config && touch docker-compose.yml config/config.yml mkdir storage config && touch docker-compose.yml
``` ```
If you are setting up Frigate on a Linux device via SSH, you can use [nano](https://itsfoss.com/nano-editor-guide/) to edit the following files. If you prefer to edit remote files with a full editor instead of a terminal, I recommend using [Visual Studio Code](https://code.visualstudio.com/) with the [Remote SSH extension](https://code.visualstudio.com/docs/remote/ssh-tutorial). If you are setting up Frigate on a Linux device via SSH, you can use [nano](https://itsfoss.com/nano-editor-guide/) to edit the following files. If you prefer to edit remote files with a full editor instead of a terminal, I recommend using [Visual Studio Code](https://code.visualstudio.com/) with the [Remote SSH extension](https://code.visualstudio.com/docs/remote/ssh-tutorial).
@@ -121,22 +128,6 @@ services:
- "8554:8554" # RTSP feeds - "8554:8554" # RTSP feeds
``` ```
`config.yml`
```yaml
mqtt:
enabled: False
cameras:
dummy_camera: # <--- this will be changed to your actual camera later
enabled: False
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:554/rtsp
roles:
- detect
```
Now you should be able to start Frigate by running `docker compose up -d` from within the folder containing `docker-compose.yml`. On startup, an admin user and password will be created and outputted in the logs. You can see this by running `docker logs frigate`. Frigate should now be accessible at `https://server_ip:8971` where you can login with the `admin` user and finish the configuration using the built-in configuration editor. Now you should be able to start Frigate by running `docker compose up -d` from within the folder containing `docker-compose.yml`. On startup, an admin user and password will be created and outputted in the logs. You can see this by running `docker logs frigate`. Frigate should now be accessible at `https://server_ip:8971` where you can login with the `admin` user and finish the configuration using the built-in configuration editor.
## Configuring Frigate ## Configuring Frigate
@@ -274,13 +265,11 @@ cameras:
- 0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432 - 0,461,3,0,1919,0,1919,843,1699,492,1344,458,1346,336,973,317,869,375,866,432
``` ```
### Step 6: Enable recording and/or snapshots ### Step 6: Enable recordings
In order to see Events in the Frigate UI, either snapshots or record will need to be enabled. In order to review activity in the Frigate UI, recordings need to be enabled.
#### Record To enable recording video, add the `record` role to a stream and enable it in the config. If record is disabled in the config, it won't be possible to enable it in the UI.
To enable recording video, add the `record` role to a stream and enable it in the config. If record is disabled in the config, turning it on via the UI will not have any effect.
```yaml ```yaml
mqtt: ... mqtt: ...
@@ -307,26 +296,6 @@ If you don't have separate streams for detect and record, you would just add the
By default, Frigate will retain video of all events for 10 days. The full set of options for recording can be found [here](../configuration/reference.md). By default, Frigate will retain video of all events for 10 days. The full set of options for recording can be found [here](../configuration/reference.md).
#### Snapshots
To enable snapshots of your events, just enable it in the config. Snapshots are taken from the detect stream because it is the only stream decoded.
```yaml
mqtt: ...
detectors: ...
cameras:
name_of_your_camera: ...
detect: ...
record: ...
snapshots: # <----- Enable snapshots
enabled: True
motion: ...
```
By default, Frigate will retain snapshots of all events for 10 days. The full set of options for snapshots can be found [here](../configuration/reference.md).
### Step 7: Complete config ### Step 7: Complete config
At this point you have a complete config with basic functionality. You can see the [full config reference](../configuration/reference.md) for a complete list of configuration options. At this point you have a complete config with basic functionality. You can see the [full config reference](../configuration/reference.md) for a complete list of configuration options.
@@ -336,6 +305,8 @@ At this point you have a complete config with basic functionality. You can see t
Now that you have a working install, you can use the following documentation for additional features: Now that you have a working install, you can use the following documentation for additional features:
1. [Configuring go2rtc](configuring_go2rtc.md) - Additional live view options and RTSP relay 1. [Configuring go2rtc](configuring_go2rtc.md) - Additional live view options and RTSP relay
2. [Home Assistant Integration](../integrations/home-assistant.md) - Integrate with Home Assistant 2. [Zones](../configuration/zones.md)
3. [Masks](../configuration/masks.md) 3. [Review](../configuration/review.md)
4. [Zones](../configuration/zones.md) 4. [Masks](../configuration/masks.md)
5. [Home Assistant Integration](../integrations/home-assistant.md) - Integrate with Home Assistant

View File

@@ -546,6 +546,11 @@ def vod_ts(camera_name, start_ts, end_ts):
if recording.end_time > end_ts: if recording.end_time > end_ts:
duration -= int((recording.end_time - end_ts) * 1000) duration -= int((recording.end_time - end_ts) * 1000)
if duration == 0:
# this means the segment starts right at the end of the requested time range
# and it does not need to be included
continue
if 0 < duration < max_duration_ms: if 0 < duration < max_duration_ms:
clip["keyFrameDurations"] = [duration] clip["keyFrameDurations"] = [duration]
clips.append(clip) clips.append(clip)

View File

@@ -129,7 +129,20 @@ class Dispatcher:
elif topic == UPDATE_CAMERA_ACTIVITY: elif topic == UPDATE_CAMERA_ACTIVITY:
self.camera_activity = payload self.camera_activity = payload
elif topic == "onConnect": elif topic == "onConnect":
self.publish("camera_activity", json.dumps(self.camera_activity)) camera_status = self.camera_activity.copy()
for camera in camera_status.keys():
camera_status[camera]["config"] = {
"detect": self.config.cameras[camera].detect.enabled,
"snapshots": self.config.cameras[camera].snapshots.enabled,
"record": self.config.cameras[camera].record.enabled,
"audio": self.config.cameras[camera].audio.enabled,
"autotracking": self.config.cameras[
camera
].onvif.autotracking.enabled,
}
self.publish("camera_activity", json.dumps(camera_status))
else: else:
self.publish(topic, payload, retain=False) self.publish(topic, payload, retain=False)

View File

@@ -23,7 +23,6 @@ model_chache_dir = "/config/model_cache/rknn_cache/"
class RknnDetectorConfig(BaseDetectorConfig): class RknnDetectorConfig(BaseDetectorConfig):
type: Literal[DETECTOR_KEY] type: Literal[DETECTOR_KEY]
num_cores: int = Field(default=0, ge=0, le=3, title="Number of NPU cores to use.") num_cores: int = Field(default=0, ge=0, le=3, title="Number of NPU cores to use.")
purge_model_cache: bool = Field(default=True)
class Rknn(DetectionApi): class Rknn(DetectionApi):
@@ -36,7 +35,9 @@ class Rknn(DetectionApi):
core_mask = 2**config.num_cores - 1 core_mask = 2**config.num_cores - 1
soc = self.get_soc() soc = self.get_soc()
model_props = self.parse_model_input(config.model.path, soc) model_path = config.model.path or "deci-fp16-yolonas_s"
model_props = self.parse_model_input(model_path, soc)
if model_props["preset"]: if model_props["preset"]:
config.model.model_type = model_props["model_type"] config.model.model_type = model_props["model_type"]

View File

@@ -209,7 +209,9 @@ class AudioEventMaintainer(threading.Thread):
audio_detections = [] audio_detections = []
for label, score, _ in model_detections: for label, score, _ in model_detections:
logger.debug(f"Heard {label} with a score of {score}") logger.debug(
f"{self.config.name} heard {label} with a score of {score}"
)
if label not in self.config.audio.listen: if label not in self.config.audio.listen:
continue continue

View File

@@ -214,8 +214,7 @@ def parse_preset_hardware_acceleration_encode(
PRESETS_INPUT = { PRESETS_INPUT = {
"preset-http-jpeg-generic": _user_agent_args "preset-http-jpeg-generic": [
+ [
"-r", "-r",
"{}", "{}",
"-stream_loop", "-stream_loop",

View File

@@ -395,7 +395,8 @@ class BirdsEyeFrameManager:
[ [
cam cam
for cam, cam_data in self.cameras.items() for cam, cam_data in self.cameras.items()
if cam_data["last_active_frame"] > 0 if self.config.cameras[cam].birdseye.enabled
and cam_data["last_active_frame"] > 0
and cam_data["current_frame"] - cam_data["last_active_frame"] and cam_data["current_frame"] - cam_data["last_active_frame"]
< self.inactivity_threshold < self.inactivity_threshold
] ]

View File

@@ -503,8 +503,15 @@ class ReviewSegmentMaintainer(threading.Thread):
# temporarily make it so this event can not end # temporarily make it so this event can not end
current_segment.last_update = sys.maxsize current_segment.last_update = sys.maxsize
elif manual_info["state"] == ManualEventState.end: elif manual_info["state"] == ManualEventState.end:
self.indefinite_events[camera].pop(manual_info["event_id"]) event_id = manual_info["event_id"]
current_segment.last_update = manual_info["end_time"]
if event_id in self.indefinite_events[camera]:
self.indefinite_events[camera].pop(event_id)
current_segment.last_update = manual_info["end_time"]
else:
logger.error(
f"Event with ID {event_id} has a set duration and can not be ended manually."
)
else: else:
if topic == DetectionTypeEnum.video: if topic == DetectionTypeEnum.video:
self.check_if_new_segment( self.check_if_new_segment(

View File

@@ -4,6 +4,7 @@ import asyncio
import os import os
import shutil import shutil
import time import time
from json import JSONDecodeError
from typing import Any, Optional from typing import Any, Optional
import psutil import psutil
@@ -35,7 +36,7 @@ def get_latest_version(config: FrigateConfig) -> str:
"https://api.github.com/repos/blakeblackshear/frigate/releases/latest", "https://api.github.com/repos/blakeblackshear/frigate/releases/latest",
timeout=10, timeout=10,
) )
except RequestException: except (RequestException, JSONDecodeError):
return "unknown" return "unknown"
response = request.json() response = request.json()

View File

@@ -90,7 +90,7 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
# Remove UI fields # Remove UI fields
if new_config.get("ui"): if new_config.get("ui"):
if new_config["ui"].get("use_experimental"): if new_config["ui"].get("use_experimental"):
del new_config["ui"]["experimental"] del new_config["ui"]["use_experimental"]
if new_config["ui"].get("live_mode"): if new_config["ui"].get("live_mode"):
del new_config["ui"]["live_mode"] del new_config["ui"]["live_mode"]

1
web/.node-version Normal file
View File

@@ -0,0 +1 @@
20

424
web/package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "web-new", "name": "web-new",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.5", "@cycjimmy/jsmpeg-player": "^6.1.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0", "@radix-ui/react-aspect-ratio": "^1.1.0",
@@ -30,19 +30,19 @@
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"apexcharts": "^3.50.0", "apexcharts": "^3.52.0",
"axios": "^1.7.2", "axios": "^1.7.3",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"hls.js": "^1.5.13", "hls.js": "^1.5.14",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.1.1", "immer": "^10.1.1",
"konva": "^9.3.13", "konva": "^9.3.14",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.407.0", "lucide-react": "^0.407.0",
"monaco-yaml": "^5.1.1", "monaco-yaml": "^5.2.2",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"nosleep.js": "^0.12.0", "nosleep.js": "^0.12.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -54,7 +54,7 @@
"react-hook-form": "^7.52.1", "react-hook-form": "^7.52.1",
"react-icons": "^5.2.1", "react-icons": "^5.2.1",
"react-konva": "^18.2.10", "react-konva": "^18.2.10",
"react-router-dom": "^6.24.1", "react-router-dom": "^6.26.0",
"react-swipeable": "^7.0.1", "react-swipeable": "^7.0.1",
"react-tracked": "^2.0.0", "react-tracked": "^2.0.0",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
@@ -76,7 +76,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.4.6", "@testing-library/jest-dom": "^6.4.6",
"@types/lodash": "^4.17.6", "@types/lodash": "^4.17.7",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/react": "^18.3.2", "@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@@ -87,8 +87,8 @@
"@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0", "@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-v8": "^2.0.2", "@vitest/coverage-v8": "^2.0.5",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.2.0", "eslint-plugin-jest": "^28.2.0",
@@ -98,15 +98,15 @@
"eslint-plugin-vitest-globals": "^1.5.0", "eslint-plugin-vitest-globals": "^1.5.0",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"jsdom": "^24.0.0", "jsdom": "^24.1.1",
"msw": "^2.3.0", "msw": "^2.3.5",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"prettier": "^3.3.2", "prettier": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.9",
"typescript": "^5.5.3", "typescript": "^5.5.4",
"vite": "^5.3.3", "vite": "^5.4.0",
"vitest": "^2.0.2" "vitest": "^2.0.5"
} }
}, },
"node_modules/@aashutoshrathi/word-wrap": { "node_modules/@aashutoshrathi/word-wrap": {
@@ -233,10 +233,22 @@
"statuses": "^2.0.1" "statuses": "^2.0.1"
} }
}, },
"node_modules/@bundled-es-modules/tough-cookie": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz",
"integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==",
"dev": true,
"license": "ISC",
"dependencies": {
"@types/tough-cookie": "^4.0.5",
"tough-cookie": "^4.1.4"
}
},
"node_modules/@cycjimmy/jsmpeg-player": { "node_modules/@cycjimmy/jsmpeg-player": {
"version": "6.0.5", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.0.5.tgz", "resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.1.tgz",
"integrity": "sha512-bVNHQ7VN9ecKT5AI/6RC7zpW/y4ca68a9txeR5Wiin+jKpUn/7buMe+5NPub89A8NNeNnKPQfrD2+c76ch36mA==" "integrity": "sha512-YqT7U/3LxGu+6ikd+GGPe3rA2o6P4xrBHsWi/WRqv4n58h91fWDxS/3smneod+u6H2RnWlmXvZqx960dQ9T9gQ==",
"license": "MIT"
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
@@ -986,15 +998,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@mswjs/cookies": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mswjs/cookies/-/cookies-1.1.0.tgz",
"integrity": "sha512-0ZcCVQxifZmhwNBoQIrystCb+2sWBY2Zw8lpfJBPCHGCA/HWqehITeCRVIv4VMy8MPlaHo2w2pTHFV2pFfqKPw==",
"dev": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@mswjs/interceptors": { "node_modules/@mswjs/interceptors": {
"version": "0.29.1", "version": "0.29.1",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz",
@@ -2200,9 +2203,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@remix-run/router": { "node_modules/@remix-run/router": {
"version": "1.17.1", "version": "1.19.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.17.1.tgz", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.0.tgz",
"integrity": "sha512-mCOMec4BKd6BRGBZeSnGiIgwsbLGp3yhVqAD8H+PxiRNEHgDpZb8J1TnrSDlg97t0ySKMQJTHCWBCmBpSmkF6Q==", "integrity": "sha512-zDICCLKEwbVYTS6TjYaWtHXxkdoUvD/QXvyVZjGCsWz5vyH7aFeONlPffPdW+Y/t6KT0MgXb2Mfjun9YpWN1dA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -2692,15 +2695,10 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true "dev": true
}, },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
"node_modules/@types/lodash": { "node_modules/@types/lodash": {
"version": "4.17.6", "version": "4.17.7",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.6.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz",
"integrity": "sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==", "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -2795,6 +2793,13 @@
"integrity": "sha512-QIvDlGAKyF3YJbT3QZnfC+RIvV5noyDbi+ZJ5rkaSRqxCGrYJefgXm3leZAjtoQOutZe1hCXbAg+p89/Vj4HlQ==", "integrity": "sha512-QIvDlGAKyF3YJbT3QZnfC+RIvV5noyDbi+ZJ5rkaSRqxCGrYJefgXm3leZAjtoQOutZe1hCXbAg+p89/Vj4HlQ==",
"dev": true "dev": true
}, },
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/wrap-ansi": { "node_modules/@types/wrap-ansi": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz",
@@ -3041,9 +3046,9 @@
} }
}, },
"node_modules/@vitest/coverage-v8": { "node_modules/@vitest/coverage-v8": {
"version": "2.0.2", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz",
"integrity": "sha512-iA8eb4PMid3bMc++gfQSTvYE1QL//fC8pz+rKsTUDBFjdDiy/gH45hvpqyDu5K7FHhvgG0GNNCJzTMMSFKhoxg==", "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3057,7 +3062,6 @@
"magic-string": "^0.30.10", "magic-string": "^0.30.10",
"magicast": "^0.3.4", "magicast": "^0.3.4",
"std-env": "^3.7.0", "std-env": "^3.7.0",
"strip-literal": "^2.1.0",
"test-exclude": "^7.0.1", "test-exclude": "^7.0.1",
"tinyrainbow": "^1.2.0" "tinyrainbow": "^1.2.0"
}, },
@@ -3065,18 +3069,18 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
}, },
"peerDependencies": { "peerDependencies": {
"vitest": "2.0.2" "vitest": "2.0.5"
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "2.0.2", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz",
"integrity": "sha512-nKAvxBYqcDugYZ4nJvnm5OR8eDJdgWjk4XM9owQKUjzW70q0icGV2HVnQOyYsp906xJaBDUXw0+9EHw2T8e0mQ==", "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "2.0.2", "@vitest/spy": "2.0.5",
"@vitest/utils": "2.0.2", "@vitest/utils": "2.0.5",
"chai": "^5.1.1", "chai": "^5.1.1",
"tinyrainbow": "^1.2.0" "tinyrainbow": "^1.2.0"
}, },
@@ -3085,9 +3089,9 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "2.0.2", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz",
"integrity": "sha512-SBCyOXfGVvddRd9r2PwoVR0fonQjh9BMIcBMlSzbcNwFfGr6ZhOhvBzurjvi2F4ryut2HcqiFhNeDVGwru8tLg==", "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3098,13 +3102,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "2.0.2", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz",
"integrity": "sha512-OCh437Vi8Wdbif1e0OvQcbfM3sW4s2lpmOjAE7qfLrpzJX2M7J1IQlNvEcb/fu6kaIB9n9n35wS0G2Q3en5kHg==", "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "2.0.2", "@vitest/utils": "2.0.5",
"pathe": "^1.1.2" "pathe": "^1.1.2"
}, },
"funding": { "funding": {
@@ -3112,13 +3116,13 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "2.0.2", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz",
"integrity": "sha512-Yc2ewhhZhx+0f9cSUdfzPRcsM6PhIb+S43wxE7OG0kTxqgqzo8tHkXFuFlndXeDMp09G3sY/X5OAo/RfYydf1g==", "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "2.0.2", "@vitest/pretty-format": "2.0.5",
"magic-string": "^0.30.10", "magic-string": "^0.30.10",
"pathe": "^1.1.2" "pathe": "^1.1.2"
}, },
@@ -3127,9 +3131,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "2.0.2", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz",
"integrity": "sha512-MgwJ4AZtCgqyp2d7WcQVE8aNG5vQ9zu9qMPYQHjsld/QVsrvg78beNrXdO4HYkP0lDahCO3P4F27aagIag+SGQ==", "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3140,13 +3144,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "2.0.2", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz",
"integrity": "sha512-pxCY1v7kmOCWYWjzc0zfjGTA3Wmn8PKnlPvSrsA643P1NHl1fOyXj2Q9SaNlrlFE+ivCsxM80Ov3AR82RmHCWQ==", "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "2.0.2", "@vitest/pretty-format": "2.0.5",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"loupe": "^3.1.1", "loupe": "^3.1.1",
"tinyrainbow": "^1.2.0" "tinyrainbow": "^1.2.0"
@@ -3281,9 +3285,9 @@
} }
}, },
"node_modules/apexcharts": { "node_modules/apexcharts": {
"version": "3.50.0", "version": "3.52.0",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.50.0.tgz", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.52.0.tgz",
"integrity": "sha512-LJT1PNAm+NoIU3aogL2P+ViC0y/Cjik54FdzzGV54UNnGQLBoLe5ok3fxsJDTgyez45BGYT8gqNpYKqhdfy5sg==", "integrity": "sha512-7dg0ADKs8AA89iYMZMe2sFDG0XK5PfqllKV9N+i3hKHm3vEtdhwz8AlXGm+/b0nJ6jKiaXsqci5LfVxNhtB+dA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@yr/monotone-cubic-spline": "^1.0.3", "@yr/monotone-cubic-spline": "^1.0.3",
@@ -3353,9 +3357,9 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
}, },
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.19", "version": "10.4.20",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3371,12 +3375,13 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"browserslist": "^4.23.0", "browserslist": "^4.23.3",
"caniuse-lite": "^1.0.30001599", "caniuse-lite": "^1.0.30001646",
"fraction.js": "^4.3.7", "fraction.js": "^4.3.7",
"normalize-range": "^0.1.2", "normalize-range": "^0.1.2",
"picocolors": "^1.0.0", "picocolors": "^1.0.1",
"postcss-value-parser": "^4.2.0" "postcss-value-parser": "^4.2.0"
}, },
"bin": { "bin": {
@@ -3390,9 +3395,10 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.7.2", "version": "1.7.3",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==",
"license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.0",
@@ -3454,9 +3460,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.23.0", "version": "4.23.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
"integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3472,11 +3478,12 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001587", "caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.4.668", "electron-to-chromium": "^1.5.4",
"node-releases": "^2.0.14", "node-releases": "^2.0.18",
"update-browserslist-db": "^1.0.13" "update-browserslist-db": "^1.1.0"
}, },
"bin": { "bin": {
"browserslist": "cli.js" "browserslist": "cli.js"
@@ -3529,9 +3536,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001599", "version": "1.0.30001651",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001599.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz",
"integrity": "sha512-LRAQHZ4yT1+f9LemSMeqdMpMxZcc4RMWdj4tiFe3G8tNkWK+E58g+/tzotb5cU6TbcVJLr4fySiAW7XmxQvZQA==", "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3546,7 +3553,8 @@
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
] ],
"license": "CC-BY-4.0"
}, },
"node_modules/chai": { "node_modules/chai": {
"version": "5.1.1", "version": "5.1.1",
@@ -4073,10 +4081,11 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.4.692", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.692.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.5.tgz",
"integrity": "sha512-d5rZRka9n2Y3MkWRN74IoAsxR0HK3yaAt7T50e3iT9VZmCCQDT3geXUO5ZRMhDToa1pkCeQXuNo+0g+NfDOVPA==", "integrity": "sha512-QR7/A7ZkMS8tZuoftC/jfqNkZLQO779SSW3YuZHP4eXpj3EffGLFcB/Xu9AAZQzLccTiCV+EmUo3ha4mQ9wnlA==",
"dev": true "dev": true,
"license": "ISC"
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
@@ -4136,10 +4145,11 @@
} }
}, },
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -4859,9 +4869,9 @@
"dev": true "dev": true
}, },
"node_modules/hls.js": { "node_modules/hls.js": {
"version": "1.5.13", "version": "1.5.14",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.13.tgz", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.14.tgz",
"integrity": "sha512-xRgKo84nsC7clEvSfIdgn/Tc0NOT+d7vdiL/wvkLO+0k0juc26NRBPPG1SfB8pd5bHXIjMW/F5VM8VYYkOYYdw==", "integrity": "sha512-5wLiQ2kWJMui6oUslaq8PnPOv1vjuee5gTxjJD0DSsccY12OXtDT0h137UuqjczNeHzeEYR0ROZQibKNMr7Mzg==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/html-encoding-sniffer": { "node_modules/html-encoding-sniffer": {
@@ -4898,9 +4908,9 @@
} }
}, },
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "7.0.4", "version": "7.0.5",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz",
"integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5301,9 +5311,9 @@
} }
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "24.1.0", "version": "24.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.1.tgz",
"integrity": "sha512-6gpM7pRXCwIOKxX47cgOyvyQDN/Eh0f1MeKySBV2xGdKtqJBLj8P25eY3EVCWo2mglDDzozR2r2MW4T+JiNUZA==", "integrity": "sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5313,11 +5323,11 @@
"form-data": "^4.0.0", "form-data": "^4.0.0",
"html-encoding-sniffer": "^4.0.0", "html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2", "http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.4", "https-proxy-agent": "^7.0.5",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.10", "nwsapi": "^2.2.12",
"parse5": "^7.1.2", "parse5": "^7.1.2",
"rrweb-cssom": "^0.7.0", "rrweb-cssom": "^0.7.1",
"saxes": "^6.0.0", "saxes": "^6.0.0",
"symbol-tree": "^3.2.4", "symbol-tree": "^3.2.4",
"tough-cookie": "^4.1.4", "tough-cookie": "^4.1.4",
@@ -5326,7 +5336,7 @@
"whatwg-encoding": "^3.1.1", "whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0", "whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0", "whatwg-url": "^14.0.0",
"ws": "^8.17.0", "ws": "^8.18.0",
"xml-name-validator": "^5.0.0" "xml-name-validator": "^5.0.0"
}, },
"engines": { "engines": {
@@ -5342,9 +5352,9 @@
} }
}, },
"node_modules/jsdom/node_modules/rrweb-cssom": { "node_modules/jsdom/node_modules/rrweb-cssom": {
"version": "0.7.0", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.0.tgz", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
"integrity": "sha512-KlSv0pm9kgQSRxXEMgtivPJ4h826YHsuob8pSHcfSZsSXGtvpEAie8S0AnXuObEJ7nhikOb4ahwxDm0H2yW17g==", "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -5384,9 +5394,9 @@
} }
}, },
"node_modules/konva": { "node_modules/konva": {
"version": "9.3.13", "version": "9.3.14",
"resolved": "https://registry.npmjs.org/konva/-/konva-9.3.13.tgz", "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.14.tgz",
"integrity": "sha512-hs0ysHnqjK9noZ/rkfDNJINfbNhkXMgjgkJ8uc6vU0amu05mSDtRlukz5kKHOaSnWHA6miXcHJydvPABh18Y8A==", "integrity": "sha512-Gmm5lyikGYJyogKQA7Fy6dKkfNh350V6DwfZkid0RVrGYP2cfCsxuMxgF5etKeCv7NjXYpJxKqi1dYkIkX/dcA==",
"funding": [ "funding": [
{ {
"type": "patreon", "type": "patreon",
@@ -5642,9 +5652,10 @@
"peer": true "peer": true
}, },
"node_modules/monaco-languageserver-types": { "node_modules/monaco-languageserver-types": {
"version": "0.3.2", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.3.2.tgz", "resolved": "https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.4.0.tgz",
"integrity": "sha512-KiGVYK/DiX1pnacnOjGNlM85bhV3ZTyFlM+ce7B8+KpWCbF1XJVovu51YyuGfm+K7+K54mIpT4DFX16xmi+tYA==", "integrity": "sha512-QQ3BZiU5LYkJElGncSNb5AKoJ/LCs6YBMCJMAz9EA7v+JaOdn3kx2cXpPTcZfKA5AEsR0vc97sAw+5mdNhVBmw==",
"license": "MIT",
"dependencies": { "dependencies": {
"monaco-types": "^0.1.0", "monaco-types": "^0.1.0",
"vscode-languageserver-protocol": "^3.0.0", "vscode-languageserver-protocol": "^3.0.0",
@@ -5669,6 +5680,7 @@
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/monaco-types/-/monaco-types-0.1.0.tgz", "resolved": "https://registry.npmjs.org/monaco-types/-/monaco-types-0.1.0.tgz",
"integrity": "sha512-aWK7SN9hAqNYi0WosPoMjenMeXJjwCxDibOqWffyQ/qXdzB/86xshGQobRferfmNz7BSNQ8GB0MD0oby9/5fTQ==", "integrity": "sha512-aWK7SN9hAqNYi0WosPoMjenMeXJjwCxDibOqWffyQ/qXdzB/86xshGQobRferfmNz7BSNQ8GB0MD0oby9/5fTQ==",
"license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/remcohaszing" "url": "https://github.com/sponsors/remcohaszing"
} }
@@ -5682,13 +5694,16 @@
} }
}, },
"node_modules/monaco-yaml": { "node_modules/monaco-yaml": {
"version": "5.1.1", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.1.1.tgz", "resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.2.2.tgz",
"integrity": "sha512-BuZ0/ZCGjrPNRzYMZ/MoxH8F/SdM+mATENXnpOhDYABi1Eh+QvxSszEct+ACSCarZiwLvy7m6yEF/pvW8XJkyQ==", "integrity": "sha512-NWO/UhtJATlIsqwWPzK7YfbcIvPo3riFGsUkaGxNJoGiNPOvHD8vZ83ecqMQGkHPOpgHtSbe94uokE1AJvpbyQ==",
"license": "MIT",
"workspaces": [
"examples/*"
],
"dependencies": { "dependencies": {
"@types/json-schema": "^7.0.0",
"jsonc-parser": "^3.0.0", "jsonc-parser": "^3.0.0",
"monaco-languageserver-types": "^0.3.0", "monaco-languageserver-types": "^0.4.0",
"monaco-marker-data-provider": "^1.0.0", "monaco-marker-data-provider": "^1.0.0",
"monaco-types": "^0.1.0", "monaco-types": "^0.1.0",
"monaco-worker-manager": "^2.0.0", "monaco-worker-manager": "^2.0.0",
@@ -5727,17 +5742,17 @@
"dev": true "dev": true
}, },
"node_modules/msw": { "node_modules/msw": {
"version": "2.3.1", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/msw/-/msw-2.3.1.tgz", "resolved": "https://registry.npmjs.org/msw/-/msw-2.3.5.tgz",
"integrity": "sha512-ocgvBCLn/5l3jpl1lssIb3cniuACJLoOfZu01e3n5dbJrpA5PeeWn28jCLgQDNt6d7QT8tF2fYRzm9JoEHtiig==", "integrity": "sha512-+GUI4gX5YC5Bv33epBrD+BGdmDvBg2XGruiWnI3GbIbRmMMBeZ5gs3mJ51OWSGHgJKztZ8AtZeYMMNMVrje2/Q==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bundled-es-modules/cookie": "^2.0.0", "@bundled-es-modules/cookie": "^2.0.0",
"@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/statuses": "^1.0.1",
"@bundled-es-modules/tough-cookie": "^0.1.6",
"@inquirer/confirm": "^3.0.0", "@inquirer/confirm": "^3.0.0",
"@mswjs/cookies": "^1.1.0",
"@mswjs/interceptors": "^0.29.0", "@mswjs/interceptors": "^0.29.0",
"@open-draft/until": "^2.1.0", "@open-draft/until": "^2.1.0",
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
@@ -5834,10 +5849,11 @@
} }
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.14", "version": "2.0.18",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
@@ -5889,9 +5905,9 @@
} }
}, },
"node_modules/nwsapi": { "node_modules/nwsapi": {
"version": "2.2.10", "version": "2.2.12",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz",
"integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==", "integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -6165,9 +6181,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.39", "version": "8.4.41",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz",
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -6741,12 +6757,12 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "6.24.1", "version": "6.26.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.24.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.0.tgz",
"integrity": "sha512-PTXFXGK2pyXpHzVo3rR9H7ip4lSPZZc0bHG5CARmj65fTT6qG7sTngmb6lcYu1gf3y/8KxORoy9yn59pGpCnpg==", "integrity": "sha512-wVQq0/iFYd3iZ9H2l3N3k4PL8EEHcb0XlU2Na8nEwmiXgIUElEH6gaJDtUQxJ+JFzmIXaQjfdpcGWaM6IoQGxg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.17.1" "@remix-run/router": "1.19.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -6756,13 +6772,13 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "6.24.1", "version": "6.26.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.24.1.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.0.tgz",
"integrity": "sha512-U19KtXqooqw967Vw0Qcn5cOvrX5Ejo9ORmOtJMzYWtCT4/WOfFLIZGGsVLxcd9UkBO0mSTZtXqhZBsWlHr7+Sg==", "integrity": "sha512-RRGUIiDtLrkX3uYcFiCIxKFWMcWQGMojpYZfcstc63A1+sSnVgILGIm9gNUA6na3Fm1QuPGSBQH2EMbAZOnMsQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@remix-run/router": "1.17.1", "@remix-run/router": "1.19.0",
"react-router": "6.24.1" "react-router": "6.26.0"
}, },
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -7246,7 +7262,8 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true "dev": true,
"license": "ISC"
}, },
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
@@ -7300,7 +7317,8 @@
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true "dev": true,
"license": "MIT"
}, },
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.1", "version": "2.0.1",
@@ -7426,26 +7444,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/strip-literal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.0.tgz",
"integrity": "sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^9.0.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.0.tgz",
"integrity": "sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==",
"dev": true,
"license": "MIT"
},
"node_modules/sucrase": { "node_modules/sucrase": {
"version": "3.34.0", "version": "3.34.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
@@ -7648,9 +7646,9 @@
} }
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.4", "version": "3.4.9",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz",
"integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==", "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
@@ -7931,9 +7929,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.5.3", "version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -7992,9 +7990,9 @@
} }
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.0.13", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
"integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -8010,9 +8008,10 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"escalade": "^3.1.1", "escalade": "^3.1.2",
"picocolors": "^1.0.0" "picocolors": "^1.0.1"
}, },
"bin": { "bin": {
"update-browserslist-db": "cli.js" "update-browserslist-db": "cli.js"
@@ -8120,14 +8119,14 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.3.3", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz",
"integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==", "integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.39", "postcss": "^8.4.40",
"rollup": "^4.13.0" "rollup": "^4.13.0"
}, },
"bin": { "bin": {
@@ -8147,6 +8146,7 @@
"less": "*", "less": "*",
"lightningcss": "^1.21.0", "lightningcss": "^1.21.0",
"sass": "*", "sass": "*",
"sass-embedded": "*",
"stylus": "*", "stylus": "*",
"sugarss": "*", "sugarss": "*",
"terser": "^5.4.0" "terser": "^5.4.0"
@@ -8164,6 +8164,9 @@
"sass": { "sass": {
"optional": true "optional": true
}, },
"sass-embedded": {
"optional": true
},
"stylus": { "stylus": {
"optional": true "optional": true
}, },
@@ -8176,9 +8179,9 @@
} }
}, },
"node_modules/vite-node": { "node_modules/vite-node": {
"version": "2.0.2", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.2.tgz", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz",
"integrity": "sha512-w4vkSz1Wo+NIQg8pjlEn0jQbcM/0D+xVaYjhw3cvarTanLLBh54oNiRbsT8PNK5GfuST0IlVXjsNRoNlqvY/fw==", "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -8207,19 +8210,19 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "2.0.2", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.2.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz",
"integrity": "sha512-WlpZ9neRIjNBIOQwBYfBSr0+of5ZCbxT2TVGKW4Lv0c8+srCFIiRdsP7U009t8mMn821HQ4XKgkx5dVWpyoyLw==", "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.3.0", "@ampproject/remapping": "^2.3.0",
"@vitest/expect": "2.0.2", "@vitest/expect": "2.0.5",
"@vitest/pretty-format": "^2.0.2", "@vitest/pretty-format": "^2.0.5",
"@vitest/runner": "2.0.2", "@vitest/runner": "2.0.5",
"@vitest/snapshot": "2.0.2", "@vitest/snapshot": "2.0.5",
"@vitest/spy": "2.0.2", "@vitest/spy": "2.0.5",
"@vitest/utils": "2.0.2", "@vitest/utils": "2.0.5",
"chai": "^5.1.1", "chai": "^5.1.1",
"debug": "^4.3.5", "debug": "^4.3.5",
"execa": "^8.0.1", "execa": "^8.0.1",
@@ -8230,8 +8233,8 @@
"tinypool": "^1.0.0", "tinypool": "^1.0.0",
"tinyrainbow": "^1.2.0", "tinyrainbow": "^1.2.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vite-node": "2.0.2", "vite-node": "2.0.5",
"why-is-node-running": "^2.2.2" "why-is-node-running": "^2.3.0"
}, },
"bin": { "bin": {
"vitest": "vitest.mjs" "vitest": "vitest.mjs"
@@ -8245,8 +8248,8 @@
"peerDependencies": { "peerDependencies": {
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0", "@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.0.2", "@vitest/browser": "2.0.5",
"@vitest/ui": "2.0.2", "@vitest/ui": "2.0.5",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },
@@ -8275,6 +8278,7 @@
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@@ -8283,6 +8287,7 @@
"version": "3.17.5", "version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": { "dependencies": {
"vscode-jsonrpc": "8.2.0", "vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5" "vscode-languageserver-types": "3.17.5"
@@ -8296,12 +8301,14 @@
"node_modules/vscode-languageserver-types": { "node_modules/vscode-languageserver-types": {
"version": "3.17.5", "version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
}, },
"node_modules/vscode-uri": { "node_modules/vscode-uri": {
"version": "3.0.8", "version": "3.0.8",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
"integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==" "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
"license": "MIT"
}, },
"node_modules/w3c-xmlserializer": { "node_modules/w3c-xmlserializer": {
"version": "5.0.0", "version": "5.0.0",
@@ -8386,10 +8393,11 @@
} }
}, },
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.2.2", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"siginfo": "^2.0.0", "siginfo": "^2.0.0",
"stackback": "0.0.2" "stackback": "0.0.2"
@@ -8440,9 +8448,9 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.17.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {

View File

@@ -14,7 +14,7 @@
"coverage": "vitest run --coverage" "coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.5", "@cycjimmy/jsmpeg-player": "^6.1.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0", "@radix-ui/react-aspect-ratio": "^1.1.0",
@@ -36,19 +36,19 @@
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"apexcharts": "^3.50.0", "apexcharts": "^3.52.0",
"axios": "^1.7.2", "axios": "^1.7.3",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"hls.js": "^1.5.13", "hls.js": "^1.5.14",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"immer": "^10.1.1", "immer": "^10.1.1",
"konva": "^9.3.13", "konva": "^9.3.14",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.407.0", "lucide-react": "^0.407.0",
"monaco-yaml": "^5.1.1", "monaco-yaml": "^5.2.2",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"nosleep.js": "^0.12.0", "nosleep.js": "^0.12.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -60,7 +60,7 @@
"react-hook-form": "^7.52.1", "react-hook-form": "^7.52.1",
"react-icons": "^5.2.1", "react-icons": "^5.2.1",
"react-konva": "^18.2.10", "react-konva": "^18.2.10",
"react-router-dom": "^6.24.1", "react-router-dom": "^6.26.0",
"react-swipeable": "^7.0.1", "react-swipeable": "^7.0.1",
"react-tracked": "^2.0.0", "react-tracked": "^2.0.0",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
@@ -82,7 +82,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.4.6", "@testing-library/jest-dom": "^6.4.6",
"@types/lodash": "^4.17.6", "@types/lodash": "^4.17.7",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/react": "^18.3.2", "@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@@ -93,8 +93,8 @@
"@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0", "@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-v8": "^2.0.2", "@vitest/coverage-v8": "^2.0.5",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.2.0", "eslint-plugin-jest": "^28.2.0",
@@ -104,14 +104,14 @@
"eslint-plugin-vitest-globals": "^1.5.0", "eslint-plugin-vitest-globals": "^1.5.0",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"jsdom": "^24.0.0", "jsdom": "^24.1.1",
"msw": "^2.3.0", "msw": "^2.3.5",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"prettier": "^3.3.2", "prettier": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.9",
"typescript": "^5.5.3", "typescript": "^5.5.4",
"vite": "^5.3.3", "vite": "^5.4.0",
"vitest": "^2.0.2" "vitest": "^2.0.5"
} }
} }

View File

@@ -10,6 +10,8 @@ import { Suspense, lazy } from "react";
import { Redirect } from "./components/navigation/Redirect"; import { Redirect } from "./components/navigation/Redirect";
import { cn } from "./lib/utils"; import { cn } from "./lib/utils";
import { isPWA } from "./utils/isPWA"; import { isPWA } from "./utils/isPWA";
import { ADMIN_USERS } from "@/types/user";
import useSWR from "swr";
const Live = lazy(() => import("@/pages/Live")); const Live = lazy(() => import("@/pages/Live"));
const Events = lazy(() => import("@/pages/Events")); const Events = lazy(() => import("@/pages/Events"));
@@ -22,6 +24,7 @@ const UIPlayground = lazy(() => import("@/pages/UIPlayground"));
const Logs = lazy(() => import("@/pages/Logs")); const Logs = lazy(() => import("@/pages/Logs"));
function App() { function App() {
const { data: profile } = useSWR("profile");
return ( return (
<Providers> <Providers>
<BrowserRouter basename={window.baseUrl}> <BrowserRouter basename={window.baseUrl}>
@@ -47,9 +50,13 @@ function App() {
<Route path="/export" element={<Exports />} /> <Route path="/export" element={<Exports />} />
<Route path="/plus" element={<SubmitPlus />} /> <Route path="/plus" element={<SubmitPlus />} />
<Route path="/system" element={<System />} /> <Route path="/system" element={<System />} />
<Route path="/settings" element={<Settings />} /> {ADMIN_USERS.includes(profile?.username) && (
<Route path="/config" element={<ConfigEditor />} /> <>
<Route path="/logs" element={<Logs />} /> <Route path="/settings" element={<Settings />} />
<Route path="/config" element={<ConfigEditor />} />
<Route path="/logs" element={<Logs />} />
</>
)}
<Route path="/playground" element={<UIPlayground />} /> <Route path="/playground" element={<UIPlayground />} />
<Route path="*" element={<Redirect to="/" />} /> <Route path="*" element={<Redirect to="/" />} />
</Routes> </Routes>

View File

@@ -1,7 +1,6 @@
import { baseUrl } from "./baseUrl"; import { baseUrl } from "./baseUrl";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket"; import useWebSocket, { ReadyState } from "react-use-websocket";
import { FrigateConfig } from "@/types/frigateConfig";
import { import {
FrigateCameraState, FrigateCameraState,
FrigateEvent, FrigateEvent,
@@ -9,7 +8,6 @@ import {
ToggleableSetting, ToggleableSetting,
} from "@/types/ws"; } from "@/types/ws";
import { FrigateStats } from "@/types/stats"; import { FrigateStats } from "@/types/stats";
import useSWR from "swr";
import { createContainer } from "react-tracked"; import { createContainer } from "react-tracked";
import useDeepMemo from "@/hooks/use-deep-memo"; import useDeepMemo from "@/hooks/use-deep-memo";
@@ -26,40 +24,50 @@ type WsState = {
type useValueReturn = [WsState, (update: Update) => void]; type useValueReturn = [WsState, (update: Update) => void];
function useValue(): useValueReturn { function useValue(): useValueReturn {
// basic config
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`; const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
// main state // main state
const [hasCameraState, setHasCameraState] = useState(false);
const [wsState, setWsState] = useState<WsState>({}); const [wsState, setWsState] = useState<WsState>({});
useEffect(() => { useEffect(() => {
if (!config) { if (hasCameraState) {
return;
}
const activityValue: string = wsState["camera_activity"] as string;
if (!activityValue) {
return;
}
const cameraActivity: { [key: string]: object } = JSON.parse(activityValue);
if (!cameraActivity) {
return; return;
} }
const cameraStates: WsState = {}; const cameraStates: WsState = {};
Object.keys(config.cameras).forEach((camera) => { Object.entries(cameraActivity).forEach(([name, state]) => {
const { name, record, detect, snapshots, audio, onvif } = const { record, detect, snapshots, audio, autotracking } =
config.cameras[camera]; // @ts-expect-error we know this is correct
cameraStates[`${name}/recordings/state`] = record.enabled ? "ON" : "OFF"; state["config"];
cameraStates[`${name}/detect/state`] = detect.enabled ? "ON" : "OFF"; cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
cameraStates[`${name}/snapshots/state`] = snapshots.enabled cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
? "ON" cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
: "OFF"; cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
cameraStates[`${name}/audio/state`] = audio.enabled ? "ON" : "OFF"; cameraStates[`${name}/ptz_autotracker/state`] = autotracking
cameraStates[`${name}/ptz_autotracker/state`] = onvif.autotracking.enabled
? "ON" ? "ON"
: "OFF"; : "OFF";
}); });
setWsState({ ...wsState, ...cameraStates }); setWsState({ ...wsState, ...cameraStates });
setHasCameraState(true);
// we only want this to run initially when the config is loaded // we only want this to run initially when the config is loaded
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); }, [wsState]);
// ws handler // ws handler
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, { const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {

View File

@@ -2,6 +2,7 @@
import * as React from "react"; import * as React from "react";
import { baseUrl } from "../../api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -43,7 +44,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
setIsLoading(true); setIsLoading(true);
try { try {
await axios.post( await axios.post(
"/api/login", "/login",
{ {
user: values.user, user: values.user,
password: values.password, password: values.password,
@@ -54,7 +55,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
}, },
}, },
); );
window.location.href = "/"; window.location.href = baseUrl;
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const err = error as AxiosError; const err = error as AxiosError;

View File

@@ -12,16 +12,21 @@ import { isCurrentHour } from "@/utils/dateUtil";
import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { isSafari } from "react-device-detect"; import { isDesktop, isSafari } from "react-device-detect";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6";
type AnimatedEventCardProps = { type AnimatedEventCardProps = {
event: ReviewSegment; event: ReviewSegment;
selectedGroup?: string; selectedGroup?: string;
updateEvents: () => void;
}; };
export function AnimatedEventCard({ export function AnimatedEventCard({
event, event,
selectedGroup, selectedGroup,
updateEvents,
}: AnimatedEventCardProps) { }: AnimatedEventCardProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const apiHost = useApiHost(); const apiHost = useApiHost();
@@ -57,6 +62,9 @@ export function AnimatedEventCard({
}; };
}, [visibilityListener]); }, [visibilityListener]);
const [isLoaded, setIsLoaded] = useState(false);
const [isHovered, setIsHovered] = useState(false);
// interaction // interaction
const navigate = useNavigate(); const navigate = useNavigate();
@@ -99,7 +107,26 @@ export function AnimatedEventCard({
style={{ style={{
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
}} }}
onMouseEnter={isDesktop ? () => setIsHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setIsHovered(false) : undefined}
> >
{isHovered && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="absolute right-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
size="xs"
onClick={async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] });
updateEvents();
}}
>
<FaCircleCheck className="size-3 text-white" />
</Button>
</TooltipTrigger>
<TooltipContent>Mark as Reviewed</TooltipContent>
</Tooltip>
)}
<div <div
className="size-full cursor-pointer overflow-hidden rounded md:rounded-lg" className="size-full cursor-pointer overflow-hidden rounded md:rounded-lg"
onClick={onOpenReview} onClick={onOpenReview}
@@ -109,6 +136,7 @@ export function AnimatedEventCard({
className="size-full select-none" className="size-full select-none"
src={`${apiHost}${event.thumb_path.replace("/media/frigate/", "")}`} src={`${apiHost}${event.thumb_path.replace("/media/frigate/", "")}`}
loading={isSafari ? "eager" : "lazy"} loading={isSafari ? "eager" : "lazy"}
onLoad={() => setIsLoaded(true)}
/> />
) : ( ) : (
<> <>
@@ -122,6 +150,11 @@ export function AnimatedEventCard({
setReviewed={() => {}} setReviewed={() => {}}
setIgnoreClick={() => {}} setIgnoreClick={() => {}}
isPlayingBack={() => {}} isPlayingBack={() => {}}
onTimeUpdate={() => {
if (!isLoaded) {
setIsLoaded(true);
}
}}
windowVisible={windowVisible} windowVisible={windowVisible}
/> />
) : ( ) : (
@@ -132,6 +165,11 @@ export function AnimatedEventCard({
muted muted
disableRemotePlayback disableRemotePlayback
loop loop
onTimeUpdate={() => {
if (!isLoaded) {
setIsLoaded(true);
}
}}
> >
<source <source
src={`${baseUrl}api/review/${event.id}/preview?format=mp4`} src={`${baseUrl}api/review/${event.id}/preview?format=mp4`}
@@ -142,11 +180,14 @@ export function AnimatedEventCard({
</> </>
)} )}
</div> </div>
<div className="absolute inset-x-0 bottom-0 h-6 rounded bg-gradient-to-t from-slate-900/50 to-transparent"> {isLoaded && (
<div className="absolute bottom-0 left-1 w-full text-xs text-white"> <div className="absolute inset-x-0 bottom-0 h-6 rounded bg-gradient-to-t from-slate-900/50 to-transparent">
<TimeAgo time={event.start_time * 1000} dense /> <div className="absolute bottom-0 left-1 w-full text-xs text-white">
<TimeAgo time={event.start_time * 1000} dense />
</div>
</div> </div>
</div> )}
{!isLoaded && <Skeleton className="absolute inset-0" />}
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>

View File

@@ -44,7 +44,7 @@ export default function ExportCard({
const [editName, setEditName] = useState<{ const [editName, setEditName] = useState<{
original: string; original: string;
update: string; update?: string;
}>(); }>();
const submitRename = useCallback(() => { const submitRename = useCallback(() => {
@@ -52,7 +52,7 @@ export default function ExportCard({
return; return;
} }
onRename(exportedRecording.id, editName.update); onRename(exportedRecording.id, editName.update ?? "");
setEditName(undefined); setEditName(undefined);
}, [editName, exportedRecording, onRename, setEditName]); }, [editName, exportedRecording, onRename, setEditName]);
@@ -64,7 +64,7 @@ export default function ExportCard({
modifiers.down && modifiers.down &&
!modifiers.repeat && !modifiers.repeat &&
editName && editName &&
editName.update.length > 0 (editName.update?.length ?? 0) > 0
) { ) {
submitRename(); submitRename();
} }
@@ -92,7 +92,11 @@ export default function ExportCard({
className="mt-3" className="mt-3"
type="search" type="search"
placeholder={editName?.original} placeholder={editName?.original}
value={editName?.update || editName?.original} value={
editName?.update == undefined
? editName?.original
: editName?.update
}
onChange={(e) => onChange={(e) =>
setEditName({ setEditName({
original: editName.original ?? "", original: editName.original ?? "",
@@ -124,13 +128,27 @@ export default function ExportCard({
onMouseLeave={isDesktop ? () => setHovered(false) : undefined} onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
onClick={isDesktop ? undefined : () => setHovered(!hovered)} onClick={isDesktop ? undefined : () => setHovered(!hovered)}
> >
{hovered && ( {exportedRecording.in_progress ? (
<ActivityIndicator />
) : (
<> <>
<div className="absolute inset-0 z-10 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" /> {exportedRecording.thumb_path.length > 0 ? (
<img
className="absolute inset-0 aspect-video size-full rounded-lg object-contain md:rounded-2xl"
src={`${baseUrl}${exportedRecording.thumb_path.replace("/media/frigate/", "")}`}
onLoad={() => setLoading(false)}
/>
) : (
<div className="absolute inset-0 rounded-lg bg-secondary md:rounded-2xl" />
)}
</>
)}
{hovered && (
<div>
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" />
<div className="absolute right-1 top-1 flex items-center gap-2"> <div className="absolute right-1 top-1 flex items-center gap-2">
{!exportedRecording.in_progress && ( {!exportedRecording.in_progress && (
<a <a
className="z-20"
download download
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`} href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
> >
@@ -145,7 +163,7 @@ export default function ExportCard({
onClick={() => onClick={() =>
setEditName({ setEditName({
original: exportedRecording.name, original: exportedRecording.name,
update: "", update: undefined,
}) })
} }
> >
@@ -167,7 +185,7 @@ export default function ExportCard({
{!exportedRecording.in_progress && ( {!exportedRecording.in_progress && (
<Button <Button
className="absolute left-1/2 top-1/2 z-20 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white" className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
onSelect(exportedRecording); onSelect(exportedRecording);
@@ -176,27 +194,12 @@ export default function ExportCard({
<FaPlay /> <FaPlay />
</Button> </Button>
)} )}
</> </div>
)}
{exportedRecording.in_progress ? (
<ActivityIndicator />
) : (
<>
{exportedRecording.thumb_path.length > 0 ? (
<img
className="absolute inset-0 aspect-video size-full rounded-lg object-contain md:rounded-2xl"
src={`${baseUrl}${exportedRecording.thumb_path.replace("/media/frigate/", "")}`}
onLoad={() => setLoading(false)}
/>
) : (
<div className="absolute inset-0 rounded-lg bg-secondary md:rounded-2xl" />
)}
</>
)} )}
{loading && ( {loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" /> <Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
)} )}
<div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[20%] rounded-lg bg-gradient-to-t from-black/60 to-transparent md:rounded-2xl"> <div className="rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 h-[20%] rounded-lg bg-gradient-to-t from-black/60 to-transparent md:rounded-2xl">
<div className="mx-3 flex h-full items-end justify-between pb-1 text-sm capitalize text-white"> <div className="mx-3 flex h-full items-end justify-between pb-1 text-sm capitalize text-white">
{exportedRecording.name.replaceAll("_", " ")} {exportedRecording.name.replaceAll("_", " ")}
</div> </div>

View File

@@ -6,7 +6,7 @@ import { getIconForLabel } from "@/utils/iconUtil";
import { isDesktop, isIOS, isSafari } from "react-device-detect"; import { isDesktop, isIOS, isSafari } from "react-device-detect";
import useSWR from "swr"; import useSWR from "swr";
import TimeAgo from "../dynamic/TimeAgo"; import TimeAgo from "../dynamic/TimeAgo";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import useImageLoaded from "@/hooks/use-image-loaded"; import useImageLoaded from "@/hooks/use-image-loaded";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import { FaCompactDisc } from "react-icons/fa"; import { FaCompactDisc } from "react-icons/fa";
@@ -18,9 +18,22 @@ import {
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "../ui/context-menu"; } from "../ui/context-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Drawer, DrawerContent } from "../ui/drawer"; import { Drawer, DrawerContent } from "../ui/drawer";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
type ReviewCardProps = { type ReviewCardProps = {
event: ReviewSegment; event: ReviewSegment;
@@ -46,6 +59,8 @@ export default function ReviewCard({
); );
const [optionsOpen, setOptionsOpen] = useState(false); const [optionsOpen, setOptionsOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const bypassDialogRef = useRef(false);
const onMarkAsReviewed = useCallback(async () => { const onMarkAsReviewed = useCallback(async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] }); await axios.post(`reviews/viewed`, { ids: [event.id] });
@@ -92,6 +107,18 @@ export default function ReviewCard({
setOptionsOpen(false); setOptionsOpen(false);
}, [event]); }, [event]);
useKeyboardListener(["Shift"], (_, modifiers) => {
bypassDialogRef.current = modifiers.shift;
});
const handleDelete = useCallback(() => {
if (bypassDialogRef.current) {
onDelete();
} else {
setDeleteDialogOpen(true);
}
}, [bypassDialogRef, onDelete]);
const content = ( const content = (
<div <div
className="relative flex w-full cursor-pointer flex-col gap-1.5" className="relative flex w-full cursor-pointer flex-col gap-1.5"
@@ -128,15 +155,43 @@ export default function ReviewCard({
}} }}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center justify-evenly gap-1"> <Tooltip>
{event.data.objects.map((object) => { <TooltipTrigger asChild>
return getIconForLabel(object, "size-3 text-white"); <div className="flex items-center justify-evenly gap-1">
})} <>
{event.data.audio.map((audio) => { {event.data.objects.map((object) => {
return getIconForLabel(audio, "size-3 text-white"); return getIconForLabel(
})} object,
<div className="font-extra-light text-xs">{formattedDate}</div> "size-3 text-primary dark:text-white",
</div> );
})}
{event.data.audio.map((audio) => {
return getIconForLabel(
audio,
"size-3 text-primary dark:text-white",
);
})}
</>
<div className="font-extra-light text-xs">{formattedDate}</div>
</div>
</TooltipTrigger>
<TooltipContent className="capitalize">
{[
...new Set([
...(event.data.objects || []),
...(event.data.sub_labels || []),
...(event.data.audio || []),
]),
]
.filter(
(item) => item !== undefined && !item.includes("-verified"),
)
.map((text) => capitalizeFirstLetter(text))
.sort()
.join(", ")
.replaceAll("-verified", "")}
</TooltipContent>
</Tooltip>
<TimeAgo <TimeAgo
className="text-xs text-muted-foreground" className="text-xs text-muted-foreground"
time={event.start_time * 1000} time={event.start_time * 1000}
@@ -152,71 +207,129 @@ export default function ReviewCard({
if (isDesktop) { if (isDesktop) {
return ( return (
<ContextMenu> <>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger> <AlertDialog
<ContextMenuContent> open={deleteDialogOpen}
<ContextMenuItem> onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
<div >
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2" <AlertDialogContent>
onClick={onExport} <AlertDialogHeader>
> <AlertDialogTitle>Confirm Delete</AlertDialogTitle>
<FaCompactDisc className="text-secondary-foreground" /> </AlertDialogHeader>
<div className="text-primary">Export</div> <AlertDialogDescription>
</div> Are you sure you want to delete all recorded video associated with
</ContextMenuItem> this review item?
{!event.has_been_reviewed && ( <br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction className="bg-destructive" onClick={onDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ContextMenu key={event.id}>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem> <ContextMenuItem>
<div <div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2" className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed} onClick={onExport}
> >
<FaCircleCheck className="text-secondary-foreground" /> <FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div> <div className="text-primary">Export</div>
</div> </div>
</ContextMenuItem> </ContextMenuItem>
)} {!event.has_been_reviewed && (
<ContextMenuItem> <ContextMenuItem>
<div <div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2" className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onDelete} onClick={onMarkAsReviewed}
> >
<HiTrash className="text-secondary-foreground" /> <FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Delete</div> <div className="text-primary">Mark as reviewed</div>
</div> </div>
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> )}
</ContextMenu> <ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">
{bypassDialogRef.current ? "Delete Now" : "Delete"}
</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</>
); );
} }
return ( return (
<Drawer open={optionsOpen} onOpenChange={setOptionsOpen}> <>
{content} <AlertDialog
<DrawerContent> open={deleteDialogOpen}
<div onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
className="flex w-full items-center justify-start gap-2 p-2" >
onClick={onExport} <AlertDialogContent>
> <AlertDialogHeader>
<FaCompactDisc className="text-secondary-foreground" /> <AlertDialogTitle>Confirm Delete</AlertDialogTitle>
<div className="text-primary">Export</div> </AlertDialogHeader>
</div> <AlertDialogDescription>
{!event.has_been_reviewed && ( Are you sure you want to delete all recorded video associated with
this review item?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setOptionsOpen(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction className="bg-destructive" onClick={onDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Drawer open={optionsOpen} onOpenChange={setOptionsOpen}>
{content}
<DrawerContent>
<div <div
className="flex w-full items-center justify-start gap-2 p-2" className="flex w-full items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed} onClick={onExport}
> >
<FaCircleCheck className="text-secondary-foreground" /> <FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div> <div className="text-primary">Export</div>
</div> </div>
)} {!event.has_been_reviewed && (
<div <div
className="flex w-full items-center justify-start gap-2 p-2" className="flex w-full items-center justify-start gap-2 p-2"
onClick={onDelete} onClick={onMarkAsReviewed}
> >
<HiTrash className="text-secondary-foreground" /> <FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Delete</div> <div className="text-primary">Mark as reviewed</div>
</div> </div>
</DrawerContent> )}
</Drawer> <div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">
{bypassDialogRef.current ? "Delete Now" : "Delete"}
</div>
</div>
</DrawerContent>
</Drawer>
</>
); );
} }

View File

@@ -551,6 +551,14 @@ export function CameraGroupEdit({
message: "Camera group name already exists.", message: "Camera group name already exists.",
}, },
) )
.refine(
(value: string) => {
return !value.includes(".");
},
{
message: "Camera group name must not contain a period.",
},
)
.refine((value: string) => value.toLowerCase() !== "default", { .refine((value: string) => value.toLowerCase() !== "default", {
message: "Invalid camera group name.", message: "Invalid camera group name.",
}), }),

View File

@@ -1,10 +1,21 @@
import { FaCircleCheck } from "react-icons/fa6"; import { FaCircleCheck } from "react-icons/fa6";
import { useCallback } from "react"; import { useCallback, useState } from "react";
import axios from "axios"; import axios from "axios";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { FaCompactDisc } from "react-icons/fa"; import { FaCompactDisc } from "react-icons/fa";
import { HiTrash } from "react-icons/hi"; import { HiTrash } from "react-icons/hi";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
type ReviewActionGroupProps = { type ReviewActionGroupProps = {
selectedReviews: string[]; selectedReviews: string[];
@@ -34,49 +45,94 @@ export default function ReviewActionGroup({
pullLatestData(); pullLatestData();
}, [selectedReviews, setSelectedReviews, pullLatestData]); }, [selectedReviews, setSelectedReviews, pullLatestData]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bypassDialog, setBypassDialog] = useState(false);
useKeyboardListener(["Shift"], (_, modifiers) => {
setBypassDialog(modifiers.shift);
});
const handleDelete = useCallback(() => {
if (bypassDialog) {
onDelete();
} else {
setDeleteDialogOpen(true);
}
}, [bypassDialog, onDelete]);
return ( return (
<div className="absolute inset-x-2 inset-y-0 flex items-center justify-between gap-2 bg-background py-2 md:left-auto"> <>
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground"> <AlertDialog
<div className="p-1">{`${selectedReviews.length} selected`}</div> open={deleteDialogOpen}
<div className="p-1">{"|"}</div> onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
<div >
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary" <AlertDialogContent>
onClick={onClearSelected} <AlertDialogHeader>
> <AlertDialogTitle>Confirm Delete</AlertDialogTitle>
Unselect </AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete all recorded video associated with
the selected review items?
<br />
<br />
Hold the <em>Shift</em> key to bypass this dialog in the future.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-destructive" onClick={onDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="absolute inset-x-2 inset-y-0 flex items-center justify-between gap-2 bg-background py-2 md:left-auto">
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
<div className="p-1">{`${selectedReviews.length} selected`}</div>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onClearSelected}
>
Unselect
</div>
</div> </div>
</div> <div className="flex items-center gap-1 md:gap-2">
<div className="flex items-center gap-1 md:gap-2"> {selectedReviews.length == 1 && (
{selectedReviews.length == 1 && ( <Button
className="flex items-center gap-2 p-2"
size="sm"
onClick={() => {
onExport(selectedReviews[0]);
onClearSelected();
}}
>
<FaCompactDisc className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Export</div>}
</Button>
)}
<Button <Button
className="flex items-center gap-2 p-2" className="flex items-center gap-2 p-2"
size="sm" size="sm"
onClick={() => { onClick={onMarkAsReviewed}
onExport(selectedReviews[0]);
onClearSelected();
}}
> >
<FaCompactDisc className="text-secondary-foreground" /> <FaCircleCheck className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Export</div>} {isDesktop && <div className="text-primary">Mark as reviewed</div>}
</Button> </Button>
)} <Button
<Button className="flex items-center gap-2 p-2"
className="flex items-center gap-2 p-2" size="sm"
size="sm" onClick={handleDelete}
onClick={onMarkAsReviewed} >
> <HiTrash className="text-secondary-foreground" />
<FaCircleCheck className="text-secondary-foreground" /> {isDesktop && (
{isDesktop && <div className="text-primary">Mark as reviewed</div>} <div className="text-primary">
</Button> {bypassDialog ? "Delete Now" : "Delete"}
<Button </div>
className="flex items-center gap-2 p-2" )}
size="sm" </Button>
onClick={onDelete} </div>
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && <div className="text-primary">Delete</div>}
</Button>
</div> </div>
</div> </>
); );
} }

View File

@@ -136,7 +136,11 @@ export default function ReviewFilterGroup({
const filterValues = useMemo( const filterValues = useMemo(
() => ({ () => ({
cameras: Object.keys(config?.cameras || {}), cameras: Object.keys(config?.cameras ?? {}).sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
labels: Object.values(allLabels || {}), labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}), zones: Object.values(allZones || {}),
}), }),

View File

@@ -3,6 +3,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { baseUrl } from "../../api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
@@ -26,7 +27,7 @@ type AccountSettingsProps = {
export default function AccountSettings({ className }: AccountSettingsProps) { export default function AccountSettings({ className }: AccountSettingsProps) {
const { data: profile } = useSWR("profile"); const { data: profile } = useSWR("profile");
const { data: config } = useSWR("config"); const { data: config } = useSWR("config");
const logoutUrl = config?.proxy?.logout_url || "/api/logout"; const logoutUrl = config?.proxy?.logout_url || `${baseUrl}api/logout`;
const Container = isDesktop ? DropdownMenu : Drawer; const Container = isDesktop ? DropdownMenu : Drawer;
const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger; const Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;

View File

@@ -68,6 +68,8 @@ import {
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import useSWR from "swr";
import { ADMIN_USERS } from "@/types/user";
type GeneralSettingsProps = { type GeneralSettingsProps = {
className?: string; className?: string;
@@ -80,6 +82,8 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
const { send: sendRestart } = useRestart(); const { send: sendRestart } = useRestart();
const { data: profile } = useSWR("profile");
useEffect(() => { useEffect(() => {
let countdownInterval: NodeJS.Timeout; let countdownInterval: NodeJS.Timeout;
@@ -139,8 +143,18 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
</Tooltip> </Tooltip>
</Trigger> </Trigger>
<Content <Content
style={
isDesktop
? {
maxHeight:
"var(--radix-dropdown-menu-content-available-height)",
}
: {}
}
className={ className={
isDesktop ? "mr-5 w-72" : "max-h-[75dvh] overflow-hidden p-2" isDesktop
? "scrollbar-container mr-5 w-72 overflow-y-auto"
: "max-h-[75dvh] overflow-hidden p-2"
} }
> >
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">
@@ -159,48 +173,58 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<span>System metrics</span> <span>System metrics</span>
</MenuItem> </MenuItem>
</Link> </Link>
<Link to="/logs"> {ADMIN_USERS.includes(profile?.username) && (
<MenuItem <Link to="/logs">
className={ <MenuItem
isDesktop className={
? "cursor-pointer" isDesktop
: "flex w-full items-center p-2 text-sm" ? "cursor-pointer"
} : "flex w-full items-center p-2 text-sm"
> }
<LuList className="mr-2 size-4" /> >
<span>System logs</span> <LuList className="mr-2 size-4" />
</MenuItem> <span>System logs</span>
</Link> </MenuItem>
</Link>
)}
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}> {ADMIN_USERS.includes(profile?.username) && (
Configuration <>
</DropdownMenuLabel> <DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
<DropdownMenuSeparator /> Configuration
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup> <DropdownMenuGroup>
<Link to="/settings"> {ADMIN_USERS.includes(profile?.username) && (
<MenuItem <>
className={ <Link to="/settings">
isDesktop <MenuItem
? "cursor-pointer" className={
: "flex w-full items-center p-2 text-sm" isDesktop
} ? "cursor-pointer"
> : "flex w-full items-center p-2 text-sm"
<LuSettings className="mr-2 size-4" /> }
<span>Settings</span> >
</MenuItem> <LuSettings className="mr-2 size-4" />
</Link> <span>Settings</span>
<Link to="/config"> </MenuItem>
<MenuItem </Link>
className={ <Link to="/config">
isDesktop <MenuItem
? "cursor-pointer" className={
: "flex w-full items-center p-2 text-sm" isDesktop
} ? "cursor-pointer"
> : "flex w-full items-center p-2 text-sm"
<LuPenSquare className="mr-2 size-4" /> }
<span>Configuration editor</span> >
</MenuItem> <LuPenSquare className="mr-2 size-4" />
</Link> <span>Configuration editor</span>
</MenuItem>
</Link>
</>
)}
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}> <DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Appearance Appearance
</DropdownMenuLabel> </DropdownMenuLabel>
@@ -348,16 +372,24 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<span>GitHub</span> <span>GitHub</span>
</MenuItem> </MenuItem>
</a> </a>
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} /> {ADMIN_USERS.includes(profile?.username) && (
<MenuItem <>
className={ <DropdownMenuSeparator
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" className={isDesktop ? "mt-3" : "mt-1"}
} />
onClick={() => setRestartDialogOpen(true)} <MenuItem
> className={
<LuRotateCw className="mr-2 size-4" /> isDesktop
<span>Restart Frigate</span> ? "cursor-pointer"
</MenuItem> : "flex items-center p-2 text-sm"
}
onClick={() => setRestartDialogOpen(true)}
>
<LuRotateCw className="mr-2 size-4" />
<span>Restart Frigate</span>
</MenuItem>
</>
)}
</div> </div>
</Content> </Content>
</Container> </Container>

View File

@@ -6,7 +6,7 @@ import {
useState, useState,
} from "react"; } from "react";
import Hls from "hls.js"; import Hls from "hls.js";
import { isAndroid, isDesktop, isIOS, isMobile } from "react-device-detect"; import { isAndroid, isDesktop, isMobile } from "react-device-detect";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import VideoControls from "./VideoControls"; import VideoControls from "./VideoControls";
import { VideoResolutionType } from "@/types/live"; import { VideoResolutionType } from "@/types/live";
@@ -33,6 +33,7 @@ type HlsVideoPlayerProps = {
visible: boolean; visible: boolean;
currentSource: string; currentSource: string;
hotKeys: boolean; hotKeys: boolean;
supportsFullscreen: boolean;
fullscreen: boolean; fullscreen: boolean;
onClipEnded?: () => void; onClipEnded?: () => void;
onPlayerLoaded?: () => void; onPlayerLoaded?: () => void;
@@ -49,6 +50,7 @@ export default function HlsVideoPlayer({
visible, visible,
currentSource, currentSource,
hotKeys, hotKeys,
supportsFullscreen,
fullscreen, fullscreen,
onClipEnded, onClipEnded,
onPlayerLoaded, onPlayerLoaded,
@@ -180,7 +182,7 @@ export default function HlsVideoPlayer({
seek: true, seek: true,
playbackRate: true, playbackRate: true,
plusUpload: config?.plus?.enabled == true, plusUpload: config?.plus?.enabled == true,
fullscreen: !isIOS, fullscreen: supportsFullscreen,
}} }}
setControlsOpen={setControlsOpen} setControlsOpen={setControlsOpen}
setMuted={(muted) => setMuted(muted, true)} setMuted={(muted) => setMuted(muted, true)}

View File

@@ -13,19 +13,19 @@ import {
LivePlayerMode, LivePlayerMode,
VideoResolutionType, VideoResolutionType,
} from "@/types/live"; } from "@/types/live";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import Chip from "../indicators/Chip"; import Chip from "../indicators/Chip";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TbExclamationCircle } from "react-icons/tb"; import { TbExclamationCircle } from "react-icons/tb";
import { TooltipPortal } from "@radix-ui/react-tooltip";
type LivePlayerProps = { type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void; cameraRef?: (ref: HTMLDivElement | null) => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>; containerRef?: React.MutableRefObject<HTMLDivElement | null>;
className?: string; className?: string;
cameraConfig: CameraConfig; cameraConfig: CameraConfig;
preferredLiveMode?: LivePlayerMode; preferredLiveMode: LivePlayerMode;
showStillWithoutActivity?: boolean; showStillWithoutActivity?: boolean;
windowVisible?: boolean; windowVisible?: boolean;
playAudio?: boolean; playAudio?: boolean;
@@ -36,6 +36,7 @@ type LivePlayerProps = {
onClick?: () => void; onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void; onError?: (error: LivePlayerError) => void;
onResetLiveMode?: () => void;
}; };
export default function LivePlayer({ export default function LivePlayer({
@@ -54,6 +55,7 @@ export default function LivePlayer({
onClick, onClick,
setFullResolution, setFullResolution,
onError, onError,
onResetLiveMode,
}: LivePlayerProps) { }: LivePlayerProps) {
const internalContainerRef = useRef<HTMLDivElement | null>(null); const internalContainerRef = useRef<HTMLDivElement | null>(null);
// camera activity // camera activity
@@ -70,8 +72,6 @@ export default function LivePlayer({
// camera live state // camera live state
const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode);
const [liveReady, setLiveReady] = useState(false); const [liveReady, setLiveReady] = useState(false);
const liveReadyRef = useRef(liveReady); const liveReadyRef = useRef(liveReady);
@@ -91,6 +91,7 @@ export default function LivePlayer({
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (liveReadyRef.current && !cameraActiveRef.current) { if (liveReadyRef.current && !cameraActiveRef.current) {
setLiveReady(false); setLiveReady(false);
onResetLiveMode?.();
} }
}, 500); }, 500);
@@ -152,7 +153,7 @@ export default function LivePlayer({
let player; let player;
if (!autoLive) { if (!autoLive) {
player = null; player = null;
} else if (liveMode == "webrtc") { } else if (preferredLiveMode == "webrtc") {
player = ( player = (
<WebRtcPlayer <WebRtcPlayer
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`} className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
@@ -166,7 +167,7 @@ export default function LivePlayer({
onError={onError} onError={onError}
/> />
); );
} else if (liveMode == "mse") { } else if (preferredLiveMode == "mse") {
if ("MediaSource" in window || "ManagedMediaSource" in window) { if ("MediaSource" in window || "ManagedMediaSource" in window) {
player = ( player = (
<MSEPlayer <MSEPlayer
@@ -187,7 +188,7 @@ export default function LivePlayer({
</div> </div>
); );
} }
} else if (liveMode == "jsmpeg") { } else if (preferredLiveMode == "jsmpeg") {
if (cameraActive || !showStillWithoutActivity || liveReady) { if (cameraActive || !showStillWithoutActivity || liveReady) {
player = ( player = (
<JSMpegPlayer <JSMpegPlayer
@@ -258,23 +259,22 @@ export default function LivePlayer({
</div> </div>
</TooltipTrigger> </TooltipTrigger>
</div> </div>
<TooltipContent className="capitalize"> <TooltipPortal>
{[ <TooltipContent className="capitalize">
...new Set([ {[
...(objects || []).map(({ label, sub_label }) => ...new Set([
label.endsWith("verified") ? sub_label : label, ...(objects || []).map(({ label, sub_label }) =>
), label.endsWith("verified") ? sub_label : label,
]), ),
] ]),
.filter( ]
(label) => .filter((label) => label?.includes("-verified") == false)
label !== undefined && !label.includes("-verified"), .map((label) => capitalizeFirstLetter(label))
) .sort()
.map((label) => capitalizeFirstLetter(label)) .join(", ")
.sort() .replaceAll("-verified", "")}
.join(", ") </TooltipContent>
.replaceAll("-verified", "")} </TooltipPortal>
</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
)} )}

View File

@@ -32,6 +32,7 @@ function MSEPlayer({
onError, onError,
}: MSEPlayerProps) { }: MSEPlayerProps) {
const RECONNECT_TIMEOUT: number = 10000; const RECONNECT_TIMEOUT: number = 10000;
const BUFFERING_COOLDOWN_TIMEOUT: number = 5000;
const CODECS: string[] = [ const CODECS: string[] = [
"avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen) "avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
@@ -46,6 +47,11 @@ function MSEPlayer({
const visibilityCheck: boolean = !pip; const visibilityCheck: boolean = !pip;
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const lastJumpTimeRef = useRef(0);
const MAX_BUFFER_ENTRIES = 10; // Size of the rolling window of buffered times
const bufferTimes = useRef<number[]>([]);
const bufferIndex = useRef(0);
const [wsState, setWsState] = useState<number>(WebSocket.CLOSED); const [wsState, setWsState] = useState<number>(WebSocket.CLOSED);
const [connectTS, setConnectTS] = useState<number>(0); const [connectTS, setConnectTS] = useState<number>(0);
@@ -133,6 +139,13 @@ function MSEPlayer({
} }
}, [bufferTimeout]); }, [bufferTimeout]);
const handlePause = useCallback(() => {
// don't let the user pause the live stream
if (isPlaying && playbackEnabled) {
videoRef.current?.play();
}
}, [isPlaying, playbackEnabled]);
const onOpen = () => { const onOpen = () => {
setWsState(WebSocket.OPEN); setWsState(WebSocket.OPEN);
@@ -193,6 +206,7 @@ function MSEPlayer({
const onMse = () => { const onMse = () => {
if ("ManagedMediaSource" in window) { if ("ManagedMediaSource" in window) {
// safari
const MediaSource = window.ManagedMediaSource; const MediaSource = window.ManagedMediaSource;
msRef.current?.addEventListener( msRef.current?.addEventListener(
@@ -224,6 +238,7 @@ function MSEPlayer({
videoRef.current.srcObject = msRef.current; videoRef.current.srcObject = msRef.current;
} }
} else { } else {
// non safari
msRef.current?.addEventListener( msRef.current?.addEventListener(
"sourceopen", "sourceopen",
() => { () => {
@@ -247,15 +262,35 @@ function MSEPlayer({
}, },
{ once: true }, { once: true },
); );
videoRef.current!.src = URL.createObjectURL(msRef.current!); if (videoRef.current && msRef.current) {
videoRef.current!.srcObject = null; videoRef.current.src = URL.createObjectURL(msRef.current);
videoRef.current.srcObject = null;
}
} }
play(); play();
onmessageRef.current["mse"] = (msg) => { onmessageRef.current["mse"] = (msg) => {
if (msg.type !== "mse") return; if (msg.type !== "mse") return;
const sb = msRef.current?.addSourceBuffer(msg.value); let sb: SourceBuffer | undefined;
try {
sb = msRef.current?.addSourceBuffer(msg.value);
if (sb?.mode) {
sb.mode = "segments";
}
} catch (e) {
// Safari sometimes throws this error
if (e instanceof DOMException && e.name === "InvalidStateError") {
if (wsRef.current) {
onDisconnect();
}
onError?.("mse-decode");
return;
} else {
throw e; // Re-throw if it's not the error we're handling
}
}
sb?.addEventListener("updateend", () => { sb?.addEventListener("updateend", () => {
if (sb.updating) return; if (sb.updating) return;
@@ -302,6 +337,137 @@ function MSEPlayer({
return video.buffered.end(video.buffered.length - 1) - video.currentTime; return video.buffered.end(video.buffered.length - 1) - video.currentTime;
}; };
const jumpToLive = () => {
if (!videoRef.current) return;
const buffered = videoRef.current.buffered;
if (buffered.length > 0) {
const liveEdge = buffered.end(buffered.length - 1);
// Jump to the live edge
videoRef.current.currentTime = liveEdge - 0.75;
lastJumpTimeRef.current = Date.now();
}
};
const calculateAdaptiveBufferThreshold = () => {
const filledEntries = bufferTimes.current.length;
const sum = bufferTimes.current.reduce((a, b) => a + b, 0);
const averageBufferTime = filledEntries ? sum / filledEntries : 0;
return averageBufferTime * (isSafari || isIOS ? 3 : 1.5);
};
const calculateAdaptivePlaybackRate = (
bufferTime: number,
bufferThreshold: number,
) => {
const alpha = 0.2; // aggressiveness of playback rate increase
const beta = 0.5; // steepness of exponential growth
// don't adjust playback rate if we're close enough to live
// or if we just started streaming
if (
((bufferTime <= bufferThreshold && bufferThreshold < 3) ||
bufferTime < 3) &&
bufferTimes.current.length <= MAX_BUFFER_ENTRIES
) {
return 1;
}
const rate = 1 + alpha * Math.exp(beta * bufferTime - bufferThreshold);
return Math.min(rate, 2);
};
const onProgress = useCallback(() => {
const bufferTime = getBufferedTime(videoRef.current);
if (
videoRef.current &&
(videoRef.current.playbackRate === 1 || bufferTime < 3)
) {
if (bufferTimes.current.length < MAX_BUFFER_ENTRIES) {
bufferTimes.current.push(bufferTime);
} else {
bufferTimes.current[bufferIndex.current] = bufferTime;
bufferIndex.current = (bufferIndex.current + 1) % MAX_BUFFER_ENTRIES;
}
}
const bufferThreshold = calculateAdaptiveBufferThreshold();
// if we have > 3 seconds of buffered data and we're still not playing,
// something might be wrong - maybe codec issue, no audio, etc
// so mark the player as playing so that error handlers will fire
if (!isPlaying && playbackEnabled && bufferTime > 3) {
setIsPlaying(true);
lastJumpTimeRef.current = Date.now();
onPlaying?.();
}
// if we have more than 10 seconds of buffer, something's wrong so error out
if (
isPlaying &&
playbackEnabled &&
(bufferThreshold > 10 || bufferTime > 10)
) {
onDisconnect();
onError?.("stalled");
}
const playbackRate = calculateAdaptivePlaybackRate(
bufferTime,
bufferThreshold,
);
// if we're above our rolling average threshold or have > 3 seconds of
// buffered data and we're playing, we may have drifted from actual live
// time
if (videoRef.current && isPlaying && playbackEnabled) {
if (
(isSafari || isIOS) &&
bufferTime > 3 &&
Date.now() - lastJumpTimeRef.current > BUFFERING_COOLDOWN_TIMEOUT
) {
// Jump to live on Safari/iOS due to a change of playback rate causing re-buffering
jumpToLive();
} else {
// increase/decrease playback rate to compensate - non Safari/iOS only
if (videoRef.current.playbackRate !== playbackRate) {
videoRef.current.playbackRate = playbackRate;
}
}
}
if (onError != undefined) {
if (videoRef.current?.paused) {
return;
}
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
setBufferTimeout(
setTimeout(() => {
if (
document.visibilityState === "visible" &&
wsRef.current != null &&
videoRef.current
) {
onDisconnect();
onError("stalled");
}
}, 3000),
);
}
}, [
bufferTimeout,
isPlaying,
onDisconnect,
onError,
onPlaying,
playbackEnabled,
]);
useEffect(() => { useEffect(() => {
if (!playbackEnabled) { if (!playbackEnabled) {
return; return;
@@ -386,45 +552,11 @@ function MSEPlayer({
handleLoadedMetadata?.(); handleLoadedMetadata?.();
onPlaying?.(); onPlaying?.();
setIsPlaying(true); setIsPlaying(true);
lastJumpTimeRef.current = Date.now();
}} }}
muted={!audioEnabled} muted={!audioEnabled}
onPause={() => videoRef.current?.play()} onPause={handlePause}
onProgress={() => { onProgress={onProgress}
// if we have > 3 seconds of buffered data and we're still not playing,
// something might be wrong - maybe codec issue, no audio, etc
// so mark the player as playing so that error handlers will fire
if (
!isPlaying &&
playbackEnabled &&
getBufferedTime(videoRef.current) > 3
) {
setIsPlaying(true);
onPlaying?.();
}
if (onError != undefined) {
if (videoRef.current?.paused) {
return;
}
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
setBufferTimeout(
setTimeout(() => {
if (
document.visibilityState === "visible" &&
wsRef.current != null &&
videoRef.current
) {
onDisconnect();
onError("stalled");
}
}, 3000),
);
}
}}
onError={(e) => { onError={(e) => {
if ( if (
// @ts-expect-error code does exist // @ts-expect-error code does exist

View File

@@ -16,6 +16,10 @@ import { isAndroid, isChrome, isMobile } from "react-device-detect";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
getPreviewForTimeRange,
usePreviewForTimeRange,
} from "@/hooks/use-camera-previews";
type PreviewPlayerProps = { type PreviewPlayerProps = {
className?: string; className?: string;
@@ -39,15 +43,11 @@ export default function PreviewPlayer({
onClick, onClick,
}: PreviewPlayerProps) { }: PreviewPlayerProps) {
const [currentHourFrame, setCurrentHourFrame] = useState<string>(); const [currentHourFrame, setCurrentHourFrame] = useState<string>();
const currentPreview = usePreviewForTimeRange(
const currentPreview = useMemo(() => { cameraPreviews,
return cameraPreviews.find( camera,
(preview) => timeRange,
preview.camera == camera && );
Math.round(preview.start) >= timeRange.after &&
Math.floor(preview.end) <= timeRange.before,
);
}, [cameraPreviews, camera, timeRange]);
if (currentPreview) { if (currentPreview) {
return ( return (
@@ -246,12 +246,7 @@ function PreviewVideoPlayer({
return; return;
} }
const preview = cameraPreviews.find( const preview = getPreviewForTimeRange(cameraPreviews, camera, timeRange);
(preview) =>
preview.camera == camera &&
Math.round(preview.start) >= timeRange.after &&
Math.floor(preview.end) <= timeRange.before,
);
if (preview != currentPreview) { if (preview != currentPreview) {
controller.newPreviewLoaded = false; controller.newPreviewLoaded = false;

View File

@@ -21,7 +21,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator"; import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
import useContextMenu from "@/hooks/use-contextmenu"; import useContextMenu from "@/hooks/use-contextmenu";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { TimeRange } from "@/types/timeline"; import { TimelineScrubMode, TimeRange } from "@/types/timeline";
import { NoThumbSlider } from "../ui/slider"; import { NoThumbSlider } from "../ui/slider";
import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview"; import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil";
@@ -234,11 +234,11 @@ export default function PreviewThumbnailPlayer({
<div <div
className={cn( className={cn(
"rounded-t-l pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent", "rounded-t-l pointer-events-none absolute inset-x-0 top-0 h-[30%] w-full bg-gradient-to-b from-black/60 to-transparent",
!isIOS && "z-10", !isSafari && "z-10",
)} )}
/> />
)} )}
<div className={cn("absolute left-0 top-2", !isIOS && "z-40")}> <div className={cn("absolute left-0 top-2", !isSafari && "z-40")}>
<Tooltip> <Tooltip>
<div <div
className="flex" className="flex"
@@ -287,7 +287,7 @@ export default function PreviewThumbnailPlayer({
<div <div
className={cn( className={cn(
"rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 h-[20%] w-full bg-gradient-to-t from-black/60 to-transparent", "rounded-b-l pointer-events-none absolute inset-x-0 bottom-0 h-[20%] w-full bg-gradient-to-t from-black/60 to-transparent",
!isIOS && "z-10", !isSafari && "z-10",
)} )}
> >
<div className="mx-3 flex h-full items-end justify-between pb-1 text-sm text-white"> <div className="mx-3 flex h-full items-end justify-between pb-1 text-sm text-white">
@@ -414,7 +414,7 @@ export function VideoPreview({
if (isSafari || (isFirefox && isMobile)) { if (isSafari || (isFirefox && isMobile)) {
playerRef.current.pause(); playerRef.current.pause();
setManualPlayback(true); setPlaybackMode("compat");
} else { } else {
playerRef.current.currentTime = playerStartTime; playerRef.current.currentTime = playerStartTime;
playerRef.current.playbackRate = PREVIEW_FPS; playerRef.current.playbackRate = PREVIEW_FPS;
@@ -453,9 +453,9 @@ export function VideoPreview({
setReviewed(); setReviewed();
if (loop && playerRef.current) { if (loop && playerRef.current) {
if (manualPlayback) { if (playbackMode != "auto") {
setManualPlayback(false); setPlaybackMode("auto");
setTimeout(() => setManualPlayback(true), 100); setTimeout(() => setPlaybackMode("compat"), 100);
} }
playerRef.current.currentTime = playerStartTime; playerRef.current.currentTime = playerStartTime;
@@ -472,7 +472,7 @@ export function VideoPreview({
playerRef.current?.pause(); playerRef.current?.pause();
} }
setManualPlayback(false); setPlaybackMode("auto");
setProgress(100.0); setProgress(100.0);
} else { } else {
setProgress(playerPercent); setProgress(playerPercent);
@@ -486,9 +486,10 @@ export function VideoPreview({
// safari is incapable of playing at a speed > 2x // safari is incapable of playing at a speed > 2x
// so manual seeking is required on iOS // so manual seeking is required on iOS
const [manualPlayback, setManualPlayback] = useState(false); const [playbackMode, setPlaybackMode] = useState<TimelineScrubMode>("auto");
useEffect(() => { useEffect(() => {
if (!manualPlayback || !playerRef.current) { if (playbackMode != "compat" || !playerRef.current) {
return; return;
} }
@@ -503,10 +504,14 @@ export function VideoPreview({
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualPlayback, playerRef]); }, [playbackMode, playerRef]);
// user interaction // user interaction
useEffect(() => {
setIgnoreClick(playbackMode != "auto" && playbackMode != "compat");
}, [playbackMode, setIgnoreClick]);
const onManualSeek = useCallback( const onManualSeek = useCallback(
(values: number[]) => { (values: number[]) => {
const value = values[0]; const value = values[0];
@@ -515,14 +520,8 @@ export function VideoPreview({
return; return;
} }
if (manualPlayback) {
setManualPlayback(false);
setIgnoreClick(true);
}
if (playerRef.current.paused == false) { if (playerRef.current.paused == false) {
playerRef.current.pause(); playerRef.current.pause();
setIgnoreClick(true);
} }
if (setReviewed) { if (setReviewed) {
@@ -536,27 +535,21 @@ export function VideoPreview({
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[ [playerDuration, playerRef, playerStartTime, setIgnoreClick],
manualPlayback,
playerDuration,
playerRef,
playerStartTime,
setIgnoreClick,
],
); );
const onStopManualSeek = useCallback(() => { const onStopManualSeek = useCallback(() => {
setTimeout(() => { setTimeout(() => {
setIgnoreClick(false);
setHoverTimeout(undefined); setHoverTimeout(undefined);
if (isSafari || (isFirefox && isMobile)) { if (isSafari || (isFirefox && isMobile)) {
setManualPlayback(true); setPlaybackMode("compat");
} else { } else {
setPlaybackMode("auto");
playerRef.current?.play(); playerRef.current?.play();
} }
}, 500); }, 500);
}, [playerRef, setIgnoreClick]); }, [playerRef]);
const onProgressHover = useCallback( const onProgressHover = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => { (event: React.MouseEvent<HTMLDivElement>) => {
@@ -572,10 +565,8 @@ export function VideoPreview({
if (hoverTimeout) { if (hoverTimeout) {
clearTimeout(hoverTimeout); clearTimeout(hoverTimeout);
} }
setHoverTimeout(setTimeout(() => onStopManualSeek(), 500));
}, },
[sliderRef, hoverTimeout, onManualSeek, onStopManualSeek, setHoverTimeout], [sliderRef, hoverTimeout, onManualSeek],
); );
return ( return (
@@ -597,14 +588,37 @@ export function VideoPreview({
{showProgress && ( {showProgress && (
<NoThumbSlider <NoThumbSlider
ref={sliderRef} ref={sliderRef}
className={`absolute inset-x-0 bottom-0 z-30 cursor-col-resize ${hoverTimeout != undefined ? "h-4" : "h-2"}`} className={`absolute inset-x-0 bottom-0 z-30 cursor-col-resize ${playbackMode == "hover" || playbackMode == "drag" ? "h-4" : "h-2"}`}
value={[progress]} value={[progress]}
onValueChange={onManualSeek} onValueChange={(event) => {
setPlaybackMode("drag");
onManualSeek(event);
}}
onValueCommit={onStopManualSeek} onValueCommit={onStopManualSeek}
min={0} min={0}
step={1} step={1}
max={100} max={100}
onMouseMove={isMobile ? undefined : onProgressHover} onMouseMove={
isMobile
? undefined
: (event) => {
if (playbackMode != "drag") {
setPlaybackMode("hover");
onProgressHover(event);
}
}
}
onMouseLeave={
isMobile
? undefined
: () => {
if (!sliderRef.current) {
return;
}
setHoverTimeout(setTimeout(() => onStopManualSeek(), 500));
}
}
/> />
)} )}
</div> </div>
@@ -642,7 +656,8 @@ export function InProgressPreview({
}/frames`, }/frames`,
{ revalidateOnFocus: false }, { revalidateOnFocus: false },
); );
const [manualFrame, setManualFrame] = useState(false);
const [playbackMode, setPlaybackMode] = useState<TimelineScrubMode>("auto");
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout>(); const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout>();
const [key, setKey] = useState(0); const [key, setKey] = useState(0);
@@ -655,7 +670,7 @@ export function InProgressPreview({
onTimeUpdate(review.start_time - PREVIEW_PADDING + key); onTimeUpdate(review.start_time - PREVIEW_PADDING + key);
} }
if (manualFrame) { if (playbackMode != "auto") {
return; return;
} }
@@ -692,19 +707,18 @@ export function InProgressPreview({
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, manualFrame, previewFrames]); }, [key, playbackMode, previewFrames]);
// user interaction // user interaction
useEffect(() => {
setIgnoreClick(playbackMode != "auto");
}, [playbackMode, setIgnoreClick]);
const onManualSeek = useCallback( const onManualSeek = useCallback(
(values: number[]) => { (values: number[]) => {
const value = values[0]; const value = values[0];
if (!manualFrame) {
setManualFrame(true);
setIgnoreClick(true);
}
if (!review.has_been_reviewed) { if (!review.has_been_reviewed) {
setReviewed(review.id); setReviewed(review.id);
} }
@@ -714,19 +728,18 @@ export function InProgressPreview({
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[manualFrame, setIgnoreClick, setManualFrame, setKey], [setIgnoreClick, setKey],
); );
const onStopManualSeek = useCallback( const onStopManualSeek = useCallback(
(values: number[]) => { (values: number[]) => {
const value = values[0]; const value = values[0];
setTimeout(() => { setTimeout(() => {
setIgnoreClick(false); setPlaybackMode("auto");
setManualFrame(false);
setKey(value - 1); setKey(value - 1);
}, 500); }, 500);
}, },
[setManualFrame, setIgnoreClick], [setPlaybackMode],
); );
const onProgressHover = useCallback( const onProgressHover = useCallback(
@@ -744,17 +757,8 @@ export function InProgressPreview({
if (hoverTimeout) { if (hoverTimeout) {
clearTimeout(hoverTimeout); clearTimeout(hoverTimeout);
} }
setHoverTimeout(setTimeout(() => onStopManualSeek(progress), 500));
}, },
[ [sliderRef, hoverTimeout, previewFrames, onManualSeek],
sliderRef,
hoverTimeout,
previewFrames,
onManualSeek,
onStopManualSeek,
setHoverTimeout,
],
); );
if (!previewFrames || previewFrames.length == 0) { if (!previewFrames || previewFrames.length == 0) {
@@ -776,14 +780,46 @@ export function InProgressPreview({
{showProgress && ( {showProgress && (
<NoThumbSlider <NoThumbSlider
ref={sliderRef} ref={sliderRef}
className={`absolute inset-x-0 bottom-0 z-30 cursor-col-resize ${manualFrame ? "h-4" : "h-2"}`} className={`absolute inset-x-0 bottom-0 z-30 cursor-col-resize ${playbackMode != "auto" ? "h-4" : "h-2"}`}
value={[key]} value={[key]}
onValueChange={onManualSeek} onValueChange={(event) => {
setPlaybackMode("drag");
onManualSeek(event);
}}
onValueCommit={onStopManualSeek} onValueCommit={onStopManualSeek}
min={0} min={0}
step={1} step={1}
max={previewFrames.length - 1} max={previewFrames.length - 1}
onMouseMove={isMobile ? undefined : onProgressHover} onMouseMove={
isMobile
? undefined
: (event) => {
if (playbackMode != "drag") {
setPlaybackMode("hover");
onProgressHover(event);
}
}
}
onMouseLeave={
isMobile
? undefined
: (event) => {
if (!sliderRef.current || !previewFrames) {
return;
}
const rect = sliderRef.current.getBoundingClientRect();
const positionX = event.clientX - rect.left;
const width = sliderRef.current.clientWidth;
const progress = [
Math.round((positionX / width) * previewFrames.length),
];
setHoverTimeout(
setTimeout(() => onStopManualSeek(progress), 500),
);
}
}
/> />
)} )}
</div> </div>

View File

@@ -141,7 +141,7 @@ export default function VideoControls({
}, [volume, muted]); }, [volume, muted]);
const onKeyboardShortcut = useCallback( const onKeyboardShortcut = useCallback(
(key: string, modifiers: KeyModifiers) => { (key: string | null, modifiers: KeyModifiers) => {
if (!modifiers.down) { if (!modifiers.down) {
return; return;
} }

View File

@@ -24,6 +24,7 @@ type DynamicVideoPlayerProps = {
startTimestamp?: number; startTimestamp?: number;
isScrubbing: boolean; isScrubbing: boolean;
hotKeys: boolean; hotKeys: boolean;
supportsFullscreen: boolean;
fullscreen: boolean; fullscreen: boolean;
onControllerReady: (controller: DynamicVideoController) => void; onControllerReady: (controller: DynamicVideoController) => void;
onTimestampUpdate?: (timestamp: number) => void; onTimestampUpdate?: (timestamp: number) => void;
@@ -40,6 +41,7 @@ export default function DynamicVideoPlayer({
startTimestamp, startTimestamp,
isScrubbing, isScrubbing,
hotKeys, hotKeys,
supportsFullscreen,
fullscreen, fullscreen,
onControllerReady, onControllerReady,
onTimestampUpdate, onTimestampUpdate,
@@ -167,7 +169,11 @@ export default function DynamicVideoPlayer({
); );
useEffect(() => { useEffect(() => {
if (!controller || !recordings) { if (!controller || !recordings?.length) {
if (recordings?.length == 0) {
setNoRecording(true);
}
return; return;
} }
@@ -197,6 +203,7 @@ export default function DynamicVideoPlayer({
visible={!(isScrubbing || isLoading)} visible={!(isScrubbing || isLoading)}
currentSource={source} currentSource={source}
hotKeys={hotKeys} hotKeys={hotKeys}
supportsFullscreen={supportsFullscreen}
fullscreen={fullscreen} fullscreen={fullscreen}
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
onPlayerLoaded={onPlayerLoaded} onPlayerLoaded={onPlayerLoaded}

View File

@@ -114,6 +114,29 @@ export function PolygonCanvas({
const mousePos = stage.getPointerPosition() ?? { x: 0, y: 0 }; const mousePos = stage.getPointerPosition() ?? { x: 0, y: 0 };
const intersection = stage.getIntersection(mousePos); const intersection = stage.getIntersection(mousePos);
// right click on desktops to delete a point
if (
e.evt instanceof MouseEvent &&
e.evt.button === 2 &&
intersection?.getClassName() == "Circle"
) {
const pointIndex = parseInt(intersection.name()?.split("-")[1]);
if (!isNaN(pointIndex)) {
const updatedPoints = activePolygon.points.filter(
(_, index) => index !== pointIndex,
);
updatedPolygons[activePolygonIndex] = {
...activePolygon,
points: updatedPoints,
pointsOrder: activePolygon.pointsOrder?.filter(
(_, index) => index !== pointIndex,
),
};
setPolygons(updatedPolygons);
}
return;
}
if ( if (
activePolygon.points.length >= 3 && activePolygon.points.length >= 3 &&
intersection?.getClassName() == "Circle" && intersection?.getClassName() == "Circle" &&
@@ -236,6 +259,9 @@ export function PolygonCanvas({
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown} onTouchStart={handleMouseDown}
onMouseOver={handleStageMouseOver} onMouseOver={handleStageMouseOver}
onContextMenu={(e) => {
e.evt.preventDefault();
}}
> >
<Layer> <Layer>
<Image <Image

View File

@@ -80,7 +80,7 @@ export default function PolygonEditControls({
<MdUndo className="text-secondary-foreground" /> <MdUndo className="text-secondary-foreground" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Undo</TooltipContent> <TooltipContent>Remove last point</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -106,6 +106,14 @@ export default function ZoneEditPane({
{ {
message: "Zone name already exists on this camera.", message: "Zone name already exists on this camera.",
}, },
)
.refine(
(value: string) => {
return !value.includes(".");
},
{
message: "Zone name must not contain a period.",
},
), ),
inertia: z.coerce inertia: z.coerce
.number() .number()

View File

@@ -1,49 +1,65 @@
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { useMemo } from "react"; import { useCallback, useEffect, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { usePersistence } from "./use-persistence";
import { LivePlayerMode } from "@/types/live"; import { LivePlayerMode } from "@/types/live";
export default function useCameraLiveMode( export default function useCameraLiveMode(
cameraConfig: CameraConfig, cameras: CameraConfig[],
preferredMode?: LivePlayerMode, windowVisible: boolean,
): LivePlayerMode | undefined { ) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode;
}>({});
const restreamEnabled = useMemo(() => { useEffect(() => {
if (!config) { if (!cameras) return;
return false;
}
return ( const mseSupported =
cameraConfig && "MediaSource" in window || "ManagedMediaSource" in window;
Object.keys(config.go2rtc.streams || {}).includes(
cameraConfig.live.stream_name, const newPreferredLiveModes = cameras.reduce(
) (acc, camera) => {
const isRestreamed =
config &&
Object.keys(config.go2rtc.streams || {}).includes(
camera.live.stream_name,
);
if (!mseSupported) {
acc[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
} else {
acc[camera.name] = isRestreamed ? "mse" : "jsmpeg";
}
return acc;
},
{} as { [key: string]: LivePlayerMode },
); );
}, [config, cameraConfig]);
const defaultLiveMode = useMemo<LivePlayerMode | undefined>(() => {
if (config) {
if (restreamEnabled) {
return preferredMode || "mse";
}
return "jsmpeg"; setPreferredLiveModes(newPreferredLiveModes);
} }, [cameras, config, windowVisible]);
return undefined; const resetPreferredLiveMode = useCallback(
}, [config, preferredMode, restreamEnabled]); (cameraName: string) => {
const [viewSource] = usePersistence<LivePlayerMode>( const mseSupported =
`${cameraConfig.name}-source`, "MediaSource" in window || "ManagedMediaSource" in window;
defaultLiveMode, const isRestreamed =
config && Object.keys(config.go2rtc.streams || {}).includes(cameraName);
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (!mseSupported) {
newModes[cameraName] = isRestreamed ? "webrtc" : "jsmpeg";
} else {
newModes[cameraName] = isRestreamed ? "mse" : "jsmpeg";
}
return newModes;
});
},
[config],
); );
if ( return { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode };
restreamEnabled &&
(preferredMode == "mse" || preferredMode == "webrtc")
) {
return preferredMode;
} else {
return viewSource;
}
} }

View File

@@ -1,6 +1,6 @@
import { Preview } from "@/types/preview"; import { Preview } from "@/types/preview";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
type OptionalCameraPreviewProps = { type OptionalCameraPreviewProps = {
@@ -8,7 +8,6 @@ type OptionalCameraPreviewProps = {
autoRefresh?: boolean; autoRefresh?: boolean;
fetchPreviews?: boolean; fetchPreviews?: boolean;
}; };
export function useCameraPreviews( export function useCameraPreviews(
initialTimeRange: TimeRange, initialTimeRange: TimeRange,
{ {
@@ -32,3 +31,33 @@ export function useCameraPreviews(
return allPreviews; return allPreviews;
} }
// we need to add a buffer of 5 seconds to the end preview times
// this ensures that if preview generation is running slowly
// and the previews are generated 1-5 seconds late
// it is not falsely thrown out.
const PREVIEW_END_BUFFER = 5; // seconds
export function getPreviewForTimeRange(
allPreviews: Preview[],
camera: string,
timeRange: TimeRange,
) {
return allPreviews.find(
(preview) =>
preview.camera == camera &&
Math.ceil(preview.start) >= timeRange.after &&
Math.floor(preview.end) <= timeRange.before + PREVIEW_END_BUFFER,
);
}
export function usePreviewForTimeRange(
allPreviews: Preview[],
camera: string,
timeRange: TimeRange,
) {
return useMemo(
() => getPreviewForTimeRange(allPreviews, camera, timeRange),
[allPreviews, camera, timeRange],
);
}

View File

@@ -1,4 +1,4 @@
import { RefObject, useCallback, useEffect, useState } from "react"; import { RefObject, useCallback, useEffect, useMemo, useState } from "react";
import nosleep from "nosleep.js"; import nosleep from "nosleep.js";
const NoSleep = new nosleep(); const NoSleep = new nosleep();
@@ -147,5 +147,31 @@ export function useFullscreen<T extends HTMLElement = HTMLElement>(
} }
}, [elementRef, handleFullscreenChange, handleFullscreenError]); }, [elementRef, handleFullscreenChange, handleFullscreenError]);
return { fullscreen, toggleFullscreen, error, clearError }; // compatibility
const supportsFullScreen = useMemo<boolean>(() => {
// @ts-expect-error we need to check that fullscreen exists
if (document.exitFullscreen) return true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((document as any).msExitFullscreen)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((document as any).webkitExitFullscreen)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((document as any).mozCancelFullScreen)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return true;
return false;
}, []);
return {
fullscreen,
toggleFullscreen,
supportsFullScreen,
error,
clearError,
};
} }

View File

@@ -4,11 +4,12 @@ export type KeyModifiers = {
down: boolean; down: boolean;
repeat: boolean; repeat: boolean;
ctrl: boolean; ctrl: boolean;
shift: boolean;
}; };
export default function useKeyboardListener( export default function useKeyboardListener(
keys: string[], keys: string[],
listener: (key: string, modifiers: KeyModifiers) => void, listener: (key: string | null, modifiers: KeyModifiers) => void,
) { ) {
const keyDownListener = useCallback( const keyDownListener = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
@@ -16,13 +17,18 @@ export default function useKeyboardListener(
return; return;
} }
const modifiers = {
down: true,
repeat: e.repeat,
ctrl: e.ctrlKey || e.metaKey,
shift: e.shiftKey,
};
if (keys.includes(e.key)) { if (keys.includes(e.key)) {
e.preventDefault(); e.preventDefault();
listener(e.key, { listener(e.key, modifiers);
down: true, } else if (e.key === "Shift" || e.key === "Control" || e.key === "Meta") {
repeat: e.repeat, listener(null, modifiers);
ctrl: e.ctrlKey || e.metaKey,
});
} }
}, },
[keys, listener], [keys, listener],
@@ -34,9 +40,18 @@ export default function useKeyboardListener(
return; return;
} }
const modifiers = {
down: false,
repeat: false,
ctrl: false,
shift: false,
};
if (keys.includes(e.key)) { if (keys.includes(e.key)) {
e.preventDefault(); e.preventDefault();
listener(e.key, { down: false, repeat: false, ctrl: false }); listener(e.key, modifiers);
} else if (e.key === "Shift" || e.key === "Control" || e.key === "Meta") {
listener(null, modifiers);
} }
}, },
[keys, listener], [keys, listener],

View File

@@ -0,0 +1,39 @@
import { useCallback, useState } from "react";
type useSessionPersistenceReturn<S> = [
value: S | undefined,
setValue: (value: S | undefined) => void,
];
export function useSessionPersistence<S>(
key: string,
defaultValue: S | undefined = undefined,
): useSessionPersistenceReturn<S> {
const [storedValue, setStoredValue] = useState(() => {
try {
const value = window.sessionStorage.getItem(key);
if (value) {
return JSON.parse(value);
} else {
window.sessionStorage.setItem(key, JSON.stringify(defaultValue));
return defaultValue;
}
} catch (err) {
return defaultValue;
}
});
const setValue = useCallback(
(newValue: S | undefined) => {
try {
window.sessionStorage.setItem(key, JSON.stringify(newValue));
// eslint-disable-next-line no-empty
} catch (err) {}
setStoredValue(newValue);
},
[key],
);
return [storedValue, setValue];
}

View File

@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import LoginPage from "@/pages/LoginPage.tsx"; import LoginPage from "@/pages/LoginPage.tsx";
import "@/api";
import "./index.css"; import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(

View File

@@ -124,12 +124,49 @@ function ConfigEditor() {
}; };
}); });
// monitoring state
const [hasChanges, setHasChanges] = useState(false);
useEffect(() => {
if (!config || !modelRef.current) {
return;
}
modelRef.current.onDidChangeContent(() => {
if (modelRef.current?.getValue() != config) {
setHasChanges(true);
} else {
setHasChanges(false);
}
});
}, [config]);
useEffect(() => { useEffect(() => {
if (config && modelRef.current) { if (config && modelRef.current) {
modelRef.current.setValue(config); modelRef.current.setValue(config);
setHasChanges(false);
} }
}, [config]); }, [config]);
useEffect(() => {
let listener: ((e: BeforeUnloadEvent) => void) | undefined;
if (hasChanges) {
listener = (e) => {
e.preventDefault();
e.returnValue = true;
return "Exit without saving?";
};
window.addEventListener("beforeunload", listener);
}
return () => {
if (listener) {
window.removeEventListener("beforeunload", listener);
}
};
}, [hasChanges]);
if (!config) { if (!config) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }

View File

@@ -101,7 +101,7 @@ export default function Events() {
// review paging // review paging
const [beforeTs, setBeforeTs] = useState(Date.now() / 1000); const [beforeTs, setBeforeTs] = useState(Math.ceil(Date.now() / 1000));
const last24Hours = useMemo(() => { const last24Hours = useMemo(() => {
return { before: beforeTs, after: getHoursAgo(24) }; return { before: beforeTs, after: getHoursAgo(24) };
}, [beforeTs]); }, [beforeTs]);
@@ -111,7 +111,7 @@ export default function Events() {
} }
return { return {
before: Math.floor(reviewSearchParams["before"]), before: Math.ceil(reviewSearchParams["before"]),
after: Math.floor(reviewSearchParams["after"]), after: Math.floor(reviewSearchParams["after"]),
}; };
}, [last24Hours, reviewSearchParams]); }, [last24Hours, reviewSearchParams]);
@@ -416,6 +416,7 @@ export default function Events() {
if (selectedReviewData) { if (selectedReviewData) {
return ( return (
<RecordingView <RecordingView
key={selectedTimeRange.before}
startCamera={selectedReviewData.camera} startCamera={selectedReviewData.camera}
startTime={selectedReviewData.start_time} startTime={selectedReviewData.start_time}
allCameras={selectedReviewData.allCameras} allCameras={selectedReviewData.allCameras}
@@ -455,5 +456,5 @@ export default function Events() {
function getHoursAgo(hours: number): number { function getHoursAgo(hours: number): number {
const now = new Date(); const now = new Date();
now.setHours(now.getHours() - hours); now.setHours(now.getHours() - hours);
return now.getTime() / 1000; return Math.ceil(now.getTime() / 1000);
} }

View File

@@ -13,9 +13,11 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { cn } from "@/lib/utils";
import { DeleteClipType, Export } from "@/types/export"; import { DeleteClipType, Export } from "@/types/export";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { isMobile } from "react-device-detect";
import { LuFolderX } from "react-icons/lu"; import { LuFolderX } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
@@ -92,6 +94,7 @@ function Exports() {
// Viewing // Viewing
const [selected, setSelected] = useState<Export>(); const [selected, setSelected] = useState<Export>();
const [selectedAspect, setSelectedAspect] = useState(0.0);
return ( return (
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2"> <div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
@@ -129,15 +132,27 @@ function Exports() {
} }
}} }}
> >
<DialogContent className="max-w-7xl"> <DialogContent
<DialogTitle>{selected?.name}</DialogTitle> className={cn("max-w-[80%]", isMobile && "landscape:max-w-[60%]")}
>
<DialogTitle className="capitalize">
{selected?.name?.replaceAll("_", " ")}
</DialogTitle>
<video <video
className="size-full rounded-lg md:rounded-2xl" className={cn(
"size-full rounded-lg md:rounded-2xl",
selectedAspect < 1.5 && "aspect-video h-full",
)}
playsInline playsInline
preload="auto" preload="auto"
autoPlay autoPlay
controls controls
muted muted
onLoadedData={(e) =>
setSelectedAspect(
e.currentTarget.videoWidth / e.currentTarget.videoHeight,
)
}
> >
<source <source
src={`${baseUrl}${selected?.video_path?.replace("/media/frigate/", "")}`} src={`${baseUrl}${selected?.video_path?.replace("/media/frigate/", "")}`}

View File

@@ -36,7 +36,8 @@ function Live() {
const mainRef = useRef<HTMLDivElement | null>(null); const mainRef = useRef<HTMLDivElement | null>(null);
const { fullscreen, toggleFullscreen } = useFullscreen(mainRef); const { fullscreen, toggleFullscreen, supportsFullScreen } =
useFullscreen(mainRef);
// document title // document title
@@ -100,6 +101,7 @@ function Live() {
<div className="size-full" ref={mainRef}> <div className="size-full" ref={mainRef}>
{selectedCameraName === "birdseye" ? ( {selectedCameraName === "birdseye" ? (
<LiveBirdseyeView <LiveBirdseyeView
supportsFullscreen={supportsFullScreen}
fullscreen={fullscreen} fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
/> />
@@ -107,6 +109,7 @@ function Live() {
<LiveCameraView <LiveCameraView
config={config} config={config}
camera={selectedCamera} camera={selectedCamera}
supportsFullscreen={supportsFullScreen}
fullscreen={fullscreen} fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
/> />

View File

@@ -286,6 +286,7 @@ function Logs() {
key={item} key={item}
className={`flex items-center justify-between gap-2 ${logService == item ? "" : "text-muted-foreground"}`} className={`flex items-center justify-between gap-2 ${logService == item ? "" : "text-muted-foreground"}`}
value={item} value={item}
data-nav-item={item}
aria-label={`Select ${item}`} aria-label={`Select ${item}`}
> >
<div className="capitalize">{item}</div> <div className="capitalize">{item}</div>

View File

@@ -43,9 +43,11 @@ import {
FaSortAmountDown, FaSortAmountDown,
FaSortAmountUp, FaSortAmountUp,
} from "react-icons/fa"; } from "react-icons/fa";
import { LuFolderX } from "react-icons/lu";
import { PiSlidersHorizontalFill } from "react-icons/pi"; import { PiSlidersHorizontalFill } from "react-icons/pi";
import useSWR from "swr"; import useSWR from "swr";
import useSWRInfinite from "swr/infinite"; import useSWRInfinite from "swr/infinite";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
const API_LIMIT = 100; const API_LIMIT = 100;
@@ -240,96 +242,137 @@ export default function SubmitPlus() {
<PlusSortSelector selectedSort={sort} setSelectedSort={setSort} /> <PlusSortSelector selectedSort={sort} setSelectedSort={setSort} />
</div> </div>
<div className="no-scrollbar flex size-full flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4"> <div className="no-scrollbar flex size-full flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
<div className="grid w-full gap-2 p-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> {!events?.length ? (
<Dialog <>
open={upload != undefined} {isValidating ? (
onOpenChange={(open) => (!open ? setUpload(undefined) : null)} <ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
> ) : (
<DialogContent className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl"> <div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<DialogHeader> <LuFolderX className="size-16" />
<DialogTitle>Submit To Frigate+</DialogTitle> No snapshots found
<DialogDescription>
Objects in locations you want to avoid are not false
positives. Submitting them as false positives will confuse the
model.
</DialogDescription>
</DialogHeader>
<img
className={`w-full ${grow} bg-black`}
src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`}
alt={`${upload?.label}`}
/>
<DialogFooter>
<Button onClick={() => setUpload(undefined)}>Cancel</Button>
<Button
className="bg-success"
onClick={() => onSubmitToPlus(false)}
>
This is a {upload?.label}
</Button>
<Button
className="text-white"
variant="destructive"
onClick={() => onSubmitToPlus(true)}
>
This is not a {upload?.label}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{events?.map((event) => {
if (event.data.type != "object" || event.plus_id) {
return;
}
return (
<div
key={event.id}
className="relative flex aspect-video w-full cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl"
onClick={() => setUpload(event)}
>
<div className="absolute left-0 top-2 z-40">
<Tooltip>
<div className="flex">
<TooltipTrigger asChild>
<div className="mx-3 pb-1 text-sm text-white">
<Chip
className={`z-0 flex items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
>
{[event.label].map((object) => {
return getIconForLabel(
object,
"size-3 text-white",
);
})}
<div className="text-xs">
{Math.round(event.data.score * 100)}%
</div>
</Chip>
</div>
</TooltipTrigger>
</div>
<TooltipContent className="capitalize">
{[event.label]
.map((text) => capitalizeFirstLetter(text))
.sort()
.join(", ")
.replaceAll("-verified", "")}
</TooltipContent>
</Tooltip>
</div>
<img
className="aspect-video h-full rounded-lg object-contain md:rounded-2xl"
src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
loading="lazy"
/>
</div> </div>
); )}
})} </>
{!isValidating && !isDone && <div ref={lastEventRef} />} ) : (
{isValidating && <ActivityIndicator />} <>
</div> <div className="grid w-full gap-2 p-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<Dialog
open={upload != undefined}
onOpenChange={(open) => (!open ? setUpload(undefined) : null)}
>
<DialogContent className="md:max-w-3xl lg:max-w-4xl xl:max-w-7xl">
<TransformWrapper
minScale={1.0}
wheel={{ smoothStep: 0.005 }}
>
<DialogHeader>
<DialogTitle>Submit To Frigate+</DialogTitle>
<DialogDescription>
Objects in locations you want to avoid are not false
positives. Submitting them as false positives will
confuse the model.
</DialogDescription>
</DialogHeader>
<TransformComponent
wrapperStyle={{
width: "100%",
height: "100%",
}}
contentStyle={{
position: "relative",
width: "100%",
height: "100%",
}}
>
{upload?.id && (
<img
className={`w-full ${grow} bg-black`}
src={`${baseUrl}api/events/${upload?.id}/snapshot.jpg`}
alt={`${upload?.label}`}
/>
)}
</TransformComponent>
<DialogFooter>
<Button onClick={() => setUpload(undefined)}>
Cancel
</Button>
<Button
className="bg-success"
onClick={() => onSubmitToPlus(false)}
>
This is a {upload?.label}
</Button>
<Button
className="text-white"
variant="destructive"
onClick={() => onSubmitToPlus(true)}
>
This is not a {upload?.label}
</Button>
</DialogFooter>
</TransformWrapper>
</DialogContent>
</Dialog>
{events?.map((event) => {
if (event.data.type != "object" || event.plus_id) {
return;
}
return (
<div
key={event.id}
className="relative flex aspect-video w-full cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl"
onClick={() => setUpload(event)}
>
<div className="absolute left-0 top-2 z-40">
<Tooltip>
<div className="flex">
<TooltipTrigger asChild>
<div className="mx-3 pb-1 text-sm text-white">
<Chip
className={`z-0 flex items-center justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500`}
>
{[event.label].map((object) => {
return getIconForLabel(
object,
"size-3 text-white",
);
})}
<div className="text-xs">
{Math.round(event.data.score * 100)}%
</div>
</Chip>
</div>
</TooltipTrigger>
</div>
<TooltipContent className="capitalize">
{[event.label]
.map((text) => capitalizeFirstLetter(text))
.sort()
.join(", ")
.replaceAll("-verified", "")}
</TooltipContent>
</Tooltip>
</div>
<img
className="aspect-video h-full rounded-lg object-contain md:rounded-2xl"
src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
loading="lazy"
/>
</div>
);
})}
</div>
{!isDone && isValidating ? (
<div className="flex w-full items-center justify-center">
<ActivityIndicator />
</div>
) : (
<div ref={lastEventRef} />
)}
</>
)}
</div> </div>
</div> </div>
); );
@@ -468,12 +511,16 @@ function PlusFilterGroup({
className="w-12" className="w-12"
inputMode="numeric" inputMode="numeric"
value={Math.round((currentScoreRange?.at(0) ?? 0.5) * 100)} value={Math.round((currentScoreRange?.at(0) ?? 0.5) * 100)}
onChange={(e) => onChange={(e) => {
setCurrentScoreRange([ const value = e.target.value;
parseInt(e.target.value) / 100.0,
currentScoreRange?.at(1) ?? 1.0, if (value) {
]) setCurrentScoreRange([
} parseInt(value) / 100.0,
currentScoreRange?.at(1) ?? 1.0,
]);
}
}}
/> />
<DualThumbSlider <DualThumbSlider
className="w-full" className="w-full"
@@ -487,12 +534,16 @@ function PlusFilterGroup({
className="w-12" className="w-12"
inputMode="numeric" inputMode="numeric"
value={Math.round((currentScoreRange?.at(1) ?? 1.0) * 100)} value={Math.round((currentScoreRange?.at(1) ?? 1.0) * 100)}
onChange={(e) => onChange={(e) => {
setCurrentScoreRange([ const value = e.target.value;
currentScoreRange?.at(0) ?? 0.5,
parseInt(e.target.value) / 100.0, if (value) {
]) setCurrentScoreRange([
} currentScoreRange?.at(0) ?? 0.5,
parseInt(value) / 100.0,
]);
}
}}
/> />
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />

View File

@@ -298,7 +298,12 @@ export interface FrigateConfig {
retry_interval: number; retry_interval: number;
}; };
go2rtc: Record<string, unknown>; go2rtc: {
streams: string[];
webrtc: {
candidates: string[];
};
};
camera_groups: { [groupName: string]: CameraGroupConfig }; camera_groups: { [groupName: string]: CameraGroupConfig };

View File

@@ -26,3 +26,5 @@ export type Timeline = {
export type TimeRange = { before: number; after: number }; export type TimeRange = { before: number; after: number };
export type TimelineType = "timeline" | "events"; export type TimelineType = "timeline" | "events";
export type TimelineScrubMode = "auto" | "drag" | "hover" | "compat";

View File

@@ -1,3 +1,5 @@
export type User = { export type User = {
username: string; username: string;
}; };
export const ADMIN_USERS: string[] = ["admin", "cking91977", "akadmin"];

View File

@@ -395,6 +395,7 @@ export default function EventView({
markAllItemsAsReviewed={markAllItemsAsReviewed} markAllItemsAsReviewed={markAllItemsAsReviewed}
onSelectReview={onSelectReview} onSelectReview={onSelectReview}
onSelectAllReviews={onSelectAllReviews} onSelectAllReviews={onSelectAllReviews}
setSelectedReviews={setSelectedReviews}
pullLatestData={pullLatestData} pullLatestData={pullLatestData}
/> />
)} )}
@@ -437,6 +438,7 @@ type DetectionReviewProps = {
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void; markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void; onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
onSelectAllReviews: () => void; onSelectAllReviews: () => void;
setSelectedReviews: (reviewIds: string[]) => void;
pullLatestData: () => void; pullLatestData: () => void;
}; };
function DetectionReview({ function DetectionReview({
@@ -455,6 +457,7 @@ function DetectionReview({
markAllItemsAsReviewed, markAllItemsAsReviewed,
onSelectReview, onSelectReview,
onSelectAllReviews, onSelectAllReviews,
setSelectedReviews,
pullLatestData, pullLatestData,
}: DetectionReviewProps) { }: DetectionReviewProps) {
const reviewTimelineRef = useRef<HTMLDivElement>(null); const reviewTimelineRef = useRef<HTMLDivElement>(null);
@@ -603,7 +606,7 @@ function DetectionReview({
// keyboard // keyboard
useKeyboardListener(["a"], (key, modifiers) => { useKeyboardListener(["a", "r"], (key, modifiers) => {
if (modifiers.repeat || !modifiers.down) { if (modifiers.repeat || !modifiers.down) {
return; return;
} }
@@ -611,6 +614,16 @@ function DetectionReview({
if (key == "a" && modifiers.ctrl) { if (key == "a" && modifiers.ctrl) {
onSelectAllReviews(); onSelectAllReviews();
} }
if (key == "r" && selectedReviews.length > 0) {
currentItems?.forEach((item) => {
if (selectedReviews.includes(item.id)) {
item.has_been_reviewed = true;
markItemAsReviewed(item);
}
});
setSelectedReviews([]);
}
}); });
return ( return (
@@ -692,6 +705,7 @@ function DetectionReview({
className="text-white" className="text-white"
variant="select" variant="select"
onClick={() => { onClick={() => {
setSelectedReviews([]);
markAllItemsAsReviewed(currentItems ?? []); markAllItemsAsReviewed(currentItems ?? []);
}} }}
> >
@@ -1057,7 +1071,7 @@ function MotionReview({
setScrubbing(scrubbing); setScrubbing(scrubbing);
}} }}
dense={isMobile} dense={isMobileOnly}
/> />
) : ( ) : (
<Skeleton className="size-full" /> <Skeleton className="size-full" />

View File

@@ -84,7 +84,11 @@ export function RecordingView({
const previewRowRef = useRef<HTMLDivElement | null>(null); const previewRowRef = useRef<HTMLDivElement | null>(null);
const previewRefs = useRef<{ [camera: string]: PreviewController }>({}); const previewRefs = useRef<{ [camera: string]: PreviewController }>({});
const [playbackStart, setPlaybackStart] = useState(startTime); const [playbackStart, setPlaybackStart] = useState(
startTime >= timeRange.after && startTime <= timeRange.before
? startTime
: timeRange.before - 60,
);
const mainCameraReviewItems = useMemo( const mainCameraReviewItems = useMemo(
() => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [], () => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [],
@@ -107,8 +111,10 @@ export function RecordingView({
return chunk.after <= startTime && chunk.before >= startTime; return chunk.after <= startTime && chunk.before >= startTime;
}), }),
); );
const currentTimeRange = useMemo( const currentTimeRange = useMemo<TimeRange>(
() => chunkedTimeRange[selectedRangeIdx], () =>
chunkedTimeRange[selectedRangeIdx] ??
chunkedTimeRange[chunkedTimeRange.length - 1],
[selectedRangeIdx, chunkedTimeRange], [selectedRangeIdx, chunkedTimeRange],
); );
const reviewFilterList = useMemo(() => { const reviewFilterList = useMemo(() => {
@@ -198,6 +204,10 @@ export function RecordingView({
const manuallySetCurrentTime = useCallback( const manuallySetCurrentTime = useCallback(
(time: number) => { (time: number) => {
if (!currentTimeRange) {
return;
}
setCurrentTime(time); setCurrentTime(time);
if (currentTimeRange.after <= time && currentTimeRange.before >= time) { if (currentTimeRange.after <= time && currentTimeRange.before >= time) {
@@ -247,7 +257,8 @@ export function RecordingView({
// fullscreen // fullscreen
const { fullscreen, toggleFullscreen } = useFullscreen(mainLayoutRef); const { fullscreen, toggleFullscreen, supportsFullScreen } =
useFullscreen(mainLayoutRef);
// layout // layout
@@ -507,7 +518,7 @@ export function RecordingView({
"pt-2 portrait:w-full", "pt-2 portrait:w-full",
mainCameraAspect == "wide" mainCameraAspect == "wide"
? "aspect-wide landscape:w-full" ? "aspect-wide landscape:w-full"
: "aspect-video landscape:h-[94%]", : "aspect-video landscape:h-[94%] landscape:xl:h-[65%]",
), ),
)} )}
style={{ style={{
@@ -539,6 +550,7 @@ export function RecordingView({
mainControllerRef.current = controller; mainControllerRef.current = controller;
}} }}
isScrubbing={scrubbing || exportMode == "timeline"} isScrubbing={scrubbing || exportMode == "timeline"}
supportsFullscreen={supportsFullScreen}
setFullResolution={setFullResolution} setFullResolution={setFullResolution}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
containerRef={mainLayoutRef} containerRef={mainLayoutRef}
@@ -708,25 +720,31 @@ function Timeline({
isMobile && "sm:grid-cols-2", isMobile && "sm:grid-cols-2",
)} )}
> >
{mainCameraReviewItems.map((review) => { {mainCameraReviewItems.length === 0 ? (
if (review.severity == "significant_motion") { <div className="mt-5 text-center text-primary">
return; No events found for this time period.
} </div>
) : (
mainCameraReviewItems.map((review) => {
if (review.severity === "significant_motion") {
return;
}
return ( return (
<ReviewCard <ReviewCard
key={review.id} key={review.id}
event={review} event={review}
currentTime={currentTime} currentTime={currentTime}
onClick={() => { onClick={() => {
manuallySetCurrentTime( manuallySetCurrentTime(
review.start_time - REVIEW_PADDING, review.start_time - REVIEW_PADDING,
true, true,
); );
}} }}
/> />
); );
})} })
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -41,6 +41,7 @@ import {
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
type DraggableGridLayoutProps = { type DraggableGridLayoutProps = {
cameras: CameraConfig[]; cameras: CameraConfig[];
@@ -75,36 +76,8 @@ export default function DraggableGridLayout({
// preferred live modes per camera // preferred live modes per camera
const [preferredLiveModes, setPreferredLiveModes] = useState<{ const { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode } =
[key: string]: LivePlayerMode; useCameraLiveMode(cameras, windowVisible);
}>({});
useEffect(() => {
if (!cameras) return;
const mseSupported =
"MediaSource" in window || "ManagedMediaSource" in window;
const newPreferredLiveModes = cameras.reduce(
(acc, camera) => {
const isRestreamed =
config &&
Object.keys(config.go2rtc.streams || {}).includes(
camera.live.stream_name,
);
if (!mseSupported) {
acc[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
} else {
acc[camera.name] = isRestreamed ? "mse" : "jsmpeg";
}
return acc;
},
{} as { [key: string]: LivePlayerMode },
);
setPreferredLiveModes(newPreferredLiveModes);
}, [cameras, config, windowVisible]);
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
@@ -477,6 +450,7 @@ export default function DraggableGridLayout({
return newModes; return newModes;
}); });
}} }}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
> >
{isEditMode && showCircles && <CornerCircles />} {isEditMode && showCircles && <CornerCircles />}
</LivePlayerGridItem> </LivePlayerGridItem>
@@ -635,6 +609,7 @@ type LivePlayerGridItemProps = {
preferredLiveMode: LivePlayerMode; preferredLiveMode: LivePlayerMode;
onClick: () => void; onClick: () => void;
onError: (e: LivePlayerError) => void; onError: (e: LivePlayerError) => void;
onResetLiveMode: () => void;
}; };
const LivePlayerGridItem = React.forwardRef< const LivePlayerGridItem = React.forwardRef<
@@ -655,6 +630,7 @@ const LivePlayerGridItem = React.forwardRef<
preferredLiveMode, preferredLiveMode,
onClick, onClick,
onError, onError,
onResetLiveMode,
...props ...props
}, },
ref, ref,
@@ -676,6 +652,7 @@ const LivePlayerGridItem = React.forwardRef<
preferredLiveMode={preferredLiveMode} preferredLiveMode={preferredLiveMode}
onClick={onClick} onClick={onClick}
onError={onError} onError={onError}
onResetLiveMode={onResetLiveMode}
containerRef={ref as React.RefObject<HTMLDivElement>} containerRef={ref as React.RefObject<HTMLDivElement>}
/> />
{children} {children}

View File

@@ -22,11 +22,13 @@ import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import useSWR from "swr"; import useSWR from "swr";
type LiveBirdseyeViewProps = { type LiveBirdseyeViewProps = {
supportsFullscreen: boolean;
fullscreen: boolean; fullscreen: boolean;
toggleFullscreen: () => void; toggleFullscreen: () => void;
}; };
export default function LiveBirdseyeView({ export default function LiveBirdseyeView({
supportsFullscreen,
fullscreen, fullscreen,
toggleFullscreen, toggleFullscreen,
}: LiveBirdseyeViewProps) { }: LiveBirdseyeViewProps) {
@@ -155,14 +157,16 @@ export default function LiveBirdseyeView({
<div <div
className={`mr-1 flex flex-row items-center gap-2 *:rounded-lg ${isMobile ? "landscape:flex-col" : ""}`} className={`mr-1 flex flex-row items-center gap-2 *:rounded-lg ${isMobile ? "landscape:flex-col" : ""}`}
> >
<CameraFeatureToggle {supportsFullscreen && (
className="p-2 md:p-0" <CameraFeatureToggle
variant={fullscreen ? "overlay" : "primary"} className="p-2 md:p-0"
Icon={fullscreen ? FaCompress : FaExpand} variant={fullscreen ? "overlay" : "primary"}
isActive={fullscreen} Icon={fullscreen ? FaCompress : FaExpand}
title={fullscreen ? "Close" : "Fullscreen"} isActive={fullscreen}
onClick={toggleFullscreen} title={fullscreen ? "Close" : "Fullscreen"}
/> onClick={toggleFullscreen}
/>
)}
{!isIOS && !isFirefox && config.birdseye.restream && ( {!isIOS && !isFirefox && config.birdseye.restream && (
<CameraFeatureToggle <CameraFeatureToggle
className="p-2 md:p-0" className="p-2 md:p-0"

View File

@@ -78,16 +78,19 @@ import { useNavigate } from "react-router-dom";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import useSWR from "swr"; import useSWR from "swr";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSessionPersistence } from "@/hooks/use-session-persistence";
type LiveCameraViewProps = { type LiveCameraViewProps = {
config?: FrigateConfig; config?: FrigateConfig;
camera: CameraConfig; camera: CameraConfig;
supportsFullscreen: boolean;
fullscreen: boolean; fullscreen: boolean;
toggleFullscreen: () => void; toggleFullscreen: () => void;
}; };
export default function LiveCameraView({ export default function LiveCameraView({
config, config,
camera, camera,
supportsFullscreen,
fullscreen, fullscreen,
toggleFullscreen, toggleFullscreen,
}: LiveCameraViewProps) { }: LiveCameraViewProps) {
@@ -194,7 +197,7 @@ export default function LiveCameraView({
// playback state // playback state
const [audio, setAudio] = useState(false); const [audio, setAudio] = useSessionPersistence("liveAudio", false);
const [mic, setMic] = useState(false); const [mic, setMic] = useState(false);
const [webRTC, setWebRTC] = useState(false); const [webRTC, setWebRTC] = useState(false);
const [pip, setPip] = useState(false); const [pip, setPip] = useState(false);
@@ -226,6 +229,10 @@ export default function LiveCameraView({
return "webrtc"; return "webrtc";
} }
if (!isRestreamed) {
return "jsmpeg";
}
return "mse"; return "mse";
}, [lowBandwidth, mic, webRTC, isRestreamed]); }, [lowBandwidth, mic, webRTC, isRestreamed]);
@@ -285,14 +292,23 @@ export default function LiveCameraView({
} }
}, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]); }, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]);
const handleError = useCallback((e: LivePlayerError) => { const handleError = useCallback(
if (e == "mse-decode") { (e: LivePlayerError) => {
setWebRTC(true); if (e) {
} else { if (
setWebRTC(false); !webRTC &&
setLowBandwidth(true); config &&
} config.go2rtc?.webrtc?.candidates?.length > 0
}, []); ) {
setWebRTC(true);
} else {
setWebRTC(false);
setLowBandwidth(true);
}
}
},
[config, webRTC],
);
return ( return (
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}> <TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
@@ -362,7 +378,7 @@ export default function LiveCameraView({
)} )}
</Button> </Button>
)} )}
{!isIOS && ( {supportsFullscreen && (
<CameraFeatureToggle <CameraFeatureToggle
className="p-2 md:p-0" className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"} variant={fullscreen ? "overlay" : "primary"}
@@ -404,13 +420,14 @@ export default function LiveCameraView({
className="p-2 md:p-0" className="p-2 md:p-0"
variant={fullscreen ? "overlay" : "primary"} variant={fullscreen ? "overlay" : "primary"}
Icon={audio ? GiSpeaker : GiSpeakerOff} Icon={audio ? GiSpeaker : GiSpeakerOff}
isActive={audio} isActive={audio ?? false}
title={`${audio ? "Disable" : "Enable"} Camera Audio`} title={`${audio ? "Disable" : "Enable"} Camera Audio`}
onClick={() => setAudio(!audio)} onClick={() => setAudio(!audio)}
/> />
)} )}
<FrigateCameraFeatures <FrigateCameraFeatures
camera={camera.name} camera={camera.name}
recordingEnabled={camera.record.enabled_in_config}
audioDetectEnabled={camera.audio.enabled_in_config} audioDetectEnabled={camera.audio.enabled_in_config}
autotrackingEnabled={ autotrackingEnabled={
camera.onvif.autotracking.enabled_in_config camera.onvif.autotracking.enabled_in_config
@@ -669,12 +686,14 @@ function PtzControlPanel({
type FrigateCameraFeaturesProps = { type FrigateCameraFeaturesProps = {
camera: string; camera: string;
recordingEnabled: boolean;
audioDetectEnabled: boolean; audioDetectEnabled: boolean;
autotrackingEnabled: boolean; autotrackingEnabled: boolean;
fullscreen: boolean; fullscreen: boolean;
}; };
function FrigateCameraFeatures({ function FrigateCameraFeatures({
camera, camera,
recordingEnabled,
audioDetectEnabled, audioDetectEnabled,
autotrackingEnabled, autotrackingEnabled,
fullscreen, fullscreen,
@@ -763,11 +782,15 @@ function FrigateCameraFeatures({
isChecked={detectState == "ON"} isChecked={detectState == "ON"}
onCheckedChange={() => sendDetect(detectState == "ON" ? "OFF" : "ON")} onCheckedChange={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
/> />
<FilterSwitch {recordingEnabled && (
label="Recording" <FilterSwitch
isChecked={recordState == "ON"} label="Recording"
onCheckedChange={() => sendRecord(recordState == "ON" ? "OFF" : "ON")} isChecked={recordState == "ON"}
/> onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
}
/>
)}
<FilterSwitch <FilterSwitch
label="Snapshots" label="Snapshots"
isChecked={snapshotState == "ON"} isChecked={snapshotState == "ON"}

View File

@@ -28,8 +28,9 @@ import DraggableGridLayout from "./DraggableGridLayout";
import { IoClose } from "react-icons/io5"; import { IoClose } from "react-icons/io5";
import { LuLayoutDashboard } from "react-icons/lu"; import { LuLayoutDashboard } from "react-icons/lu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LivePlayerError, LivePlayerMode } from "@/types/live"; import { LivePlayerError } from "@/types/live";
import { FaCompress, FaExpand } from "react-icons/fa"; import { FaCompress, FaExpand } from "react-icons/fa";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
type LiveDashboardViewProps = { type LiveDashboardViewProps = {
@@ -129,9 +130,6 @@ export default function LiveDashboardView({
// camera live views // camera live views
const [autoLiveView] = usePersistence("autoLiveView", true); const [autoLiveView] = usePersistence("autoLiveView", true);
const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode;
}>({});
const [{ height: containerHeight }] = useResizeObserver(containerRef); const [{ height: containerHeight }] = useResizeObserver(containerRef);
@@ -186,32 +184,8 @@ export default function LiveDashboardView({
}; };
}, []); }, []);
useEffect(() => { const { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode } =
if (!cameras) return; useCameraLiveMode(cameras, windowVisible);
const mseSupported =
"MediaSource" in window || "ManagedMediaSource" in window;
const newPreferredLiveModes = cameras.reduce(
(acc, camera) => {
const isRestreamed =
config &&
Object.keys(config.go2rtc.streams || {}).includes(
camera.live.stream_name,
);
if (!mseSupported) {
acc[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
} else {
acc[camera.name] = isRestreamed ? "mse" : "jsmpeg";
}
return acc;
},
{} as { [key: string]: LivePlayerMode },
);
setPreferredLiveModes(newPreferredLiveModes);
}, [cameras, config, windowVisible]);
const cameraRef = useCallback( const cameraRef = useCallback(
(node: HTMLElement | null) => { (node: HTMLElement | null) => {
@@ -315,6 +289,7 @@ export default function LiveDashboardView({
key={event.id} key={event.id}
event={event} event={event}
selectedGroup={cameraGroup} selectedGroup={cameraGroup}
updateEvents={updateEvents}
/> />
); );
})} })}
@@ -380,6 +355,7 @@ export default function LiveDashboardView({
autoLive={autoLiveView} autoLive={autoLiveView}
onClick={() => onSelectCamera(camera.name)} onClick={() => onSelectCamera(camera.name)}
onError={(e) => handleError(camera.name, e)} onError={(e) => handleError(camera.name, e)}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
/> />
); );
})} })}

View File

@@ -163,7 +163,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] }; series[key] = { name: key, data: [] };
} }
const data = stats.cpu_usages[detStats.pid.toString()].cpu; const data = stats.cpu_usages[detStats.pid.toString()]?.cpu;
if (data != undefined) { if (data != undefined) {
series[key].data.push({ series[key].data.push({
@@ -304,7 +304,7 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] }; series[key] = { name: key, data: [] };
} }
const data = stats.cpu_usages[procStats.pid.toString()].cpu; const data = stats.cpu_usages[procStats.pid.toString()]?.cpu;
if (data != undefined) { if (data != undefined) {
series[key].data.push({ series[key].data.push({
@@ -338,10 +338,14 @@ export default function GeneralMetrics({
series[key] = { name: key, data: [] }; series[key] = { name: key, data: [] };
} }
series[key].data.push({ const data = stats.cpu_usages[procStats.pid.toString()]?.mem;
x: statsIdx + 1,
y: stats.cpu_usages[procStats.pid.toString()].mem, if (data) {
}); series[key].data.push({
x: statsIdx + 1,
y: data,
});
}
} }
}); });
}); });