forked from Github/frigate
Compare commits
54 Commits
master
...
v0.14.1-we
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
005911d6a3 | ||
|
|
088ff992f8 | ||
|
|
e36dc576d3 | ||
|
|
f4f3cfa911 | ||
|
|
ca0f6e4c0a | ||
|
|
a7ccabd8f1 | ||
|
|
453a8d794e | ||
|
|
ce79898cae | ||
|
|
bf90daae2b | ||
|
|
fdb5d53960 | ||
|
|
2dc5a7f767 | ||
|
|
65ca3c8fa3 | ||
|
|
ff34af2c1f | ||
|
|
e01b6ee76b | ||
|
|
1c7ee5f4e4 | ||
|
|
d96f76c27f | ||
|
|
1da934e63c | ||
|
|
38a8d34ba5 | ||
|
|
8e31244fb3 | ||
|
|
3a124dbb84 | ||
|
|
8c23ede683 | ||
|
|
4133e454c4 | ||
|
|
4dce8ff60a | ||
|
|
2e724291db | ||
|
|
f6b61c26ae | ||
|
|
1b876bf8d3 | ||
|
|
b0d42ea116 | ||
|
|
05bc3839cc | ||
|
|
281482927a | ||
|
|
132a712341 | ||
|
|
13d121f443 | ||
|
|
67ba3dbd8b | ||
|
|
4afa7bf4e1 | ||
|
|
77bf710299 | ||
|
|
9b96211faf | ||
|
|
99e03576bf | ||
|
|
78d67484e1 | ||
|
|
e9e86cc5af | ||
|
|
70618e93b7 | ||
|
|
c84511de16 | ||
|
|
6d9590b4ec | ||
|
|
33e04fe61f | ||
|
|
9f43d10ba7 | ||
|
|
57503cc318 | ||
|
|
e563692fa2 | ||
|
|
9c2974438d | ||
|
|
54e1bd9eeb | ||
|
|
8212b66ee0 | ||
|
|
43d2986208 | ||
|
|
f8f7b74792 | ||
|
|
5069072a84 | ||
|
|
93b81756c6 | ||
|
|
4a867ddd56 | ||
|
|
a347cb5a42 |
83
.github/DISCUSSION_TEMPLATE/bug-report.yml
vendored
83
.github/DISCUSSION_TEMPLATE/bug-report.yml
vendored
@@ -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
|
||||
43
.github/DISCUSSION_TEMPLATE/camera-support.yml
vendored
43
.github/DISCUSSION_TEMPLATE/camera-support.yml
vendored
@@ -1,6 +1,16 @@
|
||||
title: "[Camera Support]: "
|
||||
labels: ["support", "triage"]
|
||||
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
|
||||
id: description
|
||||
attributes:
|
||||
@@ -11,9 +21,15 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
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:
|
||||
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
|
||||
id: config
|
||||
attributes:
|
||||
@@ -23,10 +39,18 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
id: frigatelogs
|
||||
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.
|
||||
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
|
||||
@@ -34,7 +58,7 @@ body:
|
||||
id: ffprobe
|
||||
attributes:
|
||||
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
|
||||
validations:
|
||||
required: true
|
||||
@@ -78,7 +102,7 @@ body:
|
||||
- TensorRT
|
||||
- RKNN
|
||||
- Other
|
||||
- CPU (no coral)
|
||||
- CPU (no Coral)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@@ -98,6 +122,13 @@ body:
|
||||
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 at least General and Cameras tabs.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
|
||||
31
.github/DISCUSSION_TEMPLATE/config-support.yml
vendored
31
.github/DISCUSSION_TEMPLATE/config-support.yml
vendored
@@ -1,6 +1,16 @@
|
||||
title: "[Config Support]: "
|
||||
labels: ["support", "triage"]
|
||||
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
|
||||
id: description
|
||||
attributes:
|
||||
@@ -11,7 +21,7 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
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:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -23,10 +33,18 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
id: frigatelogs
|
||||
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.
|
||||
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. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
@@ -73,6 +91,11 @@ body:
|
||||
- CPU (no coral)
|
||||
validations:
|
||||
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
|
||||
id: other
|
||||
attributes:
|
||||
|
||||
33
.github/DISCUSSION_TEMPLATE/detector-support.yml
vendored
33
.github/DISCUSSION_TEMPLATE/detector-support.yml
vendored
@@ -1,6 +1,16 @@
|
||||
title: "[Detector Support]: "
|
||||
labels: ["support", "triage"]
|
||||
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
|
||||
id: description
|
||||
attributes:
|
||||
@@ -11,7 +21,7 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
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:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -31,10 +41,18 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
id: frigatelogs
|
||||
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.
|
||||
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
|
||||
@@ -75,6 +93,13 @@ body:
|
||||
- CPU (no coral)
|
||||
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 at least General and Cameras tabs.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
|
||||
39
.github/DISCUSSION_TEMPLATE/general-support.yml
vendored
39
.github/DISCUSSION_TEMPLATE/general-support.yml
vendored
@@ -1,6 +1,16 @@
|
||||
title: "[Support]: "
|
||||
labels: ["support", "triage"]
|
||||
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
|
||||
id: description
|
||||
attributes:
|
||||
@@ -11,9 +21,15 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
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:
|
||||
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
|
||||
id: config
|
||||
attributes:
|
||||
@@ -23,10 +39,18 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
id: frigatelogs
|
||||
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.
|
||||
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
|
||||
@@ -34,7 +58,7 @@ body:
|
||||
id: ffprobe
|
||||
attributes:
|
||||
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
|
||||
validations:
|
||||
required: true
|
||||
@@ -98,6 +122,11 @@ body:
|
||||
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
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
title: "[HW Accel Support]: "
|
||||
labels: ["support", "triage"]
|
||||
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
|
||||
id: description
|
||||
attributes:
|
||||
@@ -11,9 +21,15 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
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:
|
||||
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:
|
||||
@@ -31,10 +47,18 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
id: frigatelogs
|
||||
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.
|
||||
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
|
||||
@@ -42,7 +66,7 @@ body:
|
||||
id: ffprobe
|
||||
attributes:
|
||||
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
|
||||
validations:
|
||||
required: true
|
||||
@@ -87,6 +111,13 @@ body:
|
||||
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 at least General and Cameras tabs.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
|
||||
14
.github/DISCUSSION_TEMPLATE/question.yml
vendored
14
.github/DISCUSSION_TEMPLATE/question.yml
vendored
@@ -1,9 +1,21 @@
|
||||
title: "[Question]: "
|
||||
labels: ["question"]
|
||||
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
|
||||
id: description
|
||||
attributes:
|
||||
label: "What is your question:"
|
||||
label: "What is your question?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
146
.github/DISCUSSION_TEMPLATE/report-a-bug.yml
vendored
Normal file
146
.github/DISCUSSION_TEMPLATE/report-a-bug.yml
vendored
Normal 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
|
||||
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@@ -5,7 +5,7 @@ inputs:
|
||||
required: true
|
||||
outputs:
|
||||
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:
|
||||
value: ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:cache
|
||||
runs:
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -229,7 +229,7 @@ jobs:
|
||||
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
- uses: int128/docker-manifest-create-action@v2
|
||||
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: |
|
||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ github.ref_name }}-${{ 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 }}-amd64
|
||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-rpi
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -23,10 +23,10 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Create tag variables
|
||||
run: |
|
||||
BRANCH=dev
|
||||
echo "BRANCH=${BRANCH}" >> $GITHUB_ENV
|
||||
BUILD_TYPE=$([[ "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "stable" || echo "beta")
|
||||
echo "BUILD_TYPE=${BUILD_TYPE}" >> $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
|
||||
- name: Tag and push the main image
|
||||
run: |
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
done
|
||||
|
||||
# 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}
|
||||
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}
|
||||
|
||||
26
.github/workflows/stale.yml
vendored
26
.github/workflows/stale.yml
vendored
@@ -25,17 +25,17 @@ jobs:
|
||||
- name: Print outputs
|
||||
run: echo ${{ join(steps.stale.outputs.*, ',') }}
|
||||
|
||||
clean_ghcr:
|
||||
name: Delete outdated dev container images
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delete old images
|
||||
uses: snok/container-retention-policy@v2
|
||||
with:
|
||||
image-names: dev-*
|
||||
cut-off: 60 days ago UTC
|
||||
keep-at-least: 5
|
||||
account-type: personal
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token-type: github-token
|
||||
# clean_ghcr:
|
||||
# name: Delete outdated dev container images
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: Delete old images
|
||||
# uses: snok/container-retention-policy@v2
|
||||
# with:
|
||||
# image-names: dev-*
|
||||
# cut-off: 60 days ago UTC
|
||||
# keep-at-least: 5
|
||||
# account-type: personal
|
||||
# token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# token-type: github-token
|
||||
|
||||
|
||||
20
Makefile
20
Makefile
@@ -1,8 +1,9 @@
|
||||
default_target: local
|
||||
|
||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
||||
VERSION = 0.14.0
|
||||
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
||||
VERSION = 0.14.1
|
||||
#IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
|
||||
IMAGE_REPO ?= gitea.tremendousturtle.tools/chris/frigate
|
||||
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||
CURRENT_UID := $(shell id -u)
|
||||
CURRENT_GID := $(shell id -g)
|
||||
@@ -23,15 +24,30 @@ local: version
|
||||
amd64:
|
||||
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:
|
||||
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
|
||||
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
|
||||
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
|
||||
docker run --rm --publish=5000:5000 --volume=${PWD}/config:/config frigate:latest
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function get_cpus() {
|
||||
fi
|
||||
|
||||
local cpus
|
||||
if [ -n "${quota}" ] && [ -n "${period}" ]; then
|
||||
if [ "${period}" != "0" ] && [ -n "${quota}" ] && [ -n "${period}" ]; then
|
||||
cpus=$((quota / period))
|
||||
if [ "$cpus" -eq 0 ]; then
|
||||
cpus=1
|
||||
|
||||
19
docker/webonly/Dockerfile
Normal file
19
docker/webonly/Dockerfile
Normal 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/
|
||||
@@ -466,7 +466,7 @@ snapshots:
|
||||
quality: 70
|
||||
|
||||
# Optional: Restream configuration
|
||||
# Uses https://github.com/AlexxIT/go2rtc (v1.8.3)
|
||||
# Uses https://github.com/AlexxIT/go2rtc (v1.9.2)
|
||||
go2rtc:
|
||||
|
||||
# Optional: jsmpeg stream configuration for WebUI
|
||||
|
||||
@@ -546,6 +546,11 @@ def vod_ts(camera_name, start_ts, end_ts):
|
||||
if recording.end_time > end_ts:
|
||||
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:
|
||||
clip["keyFrameDurations"] = [duration]
|
||||
clips.append(clip)
|
||||
|
||||
@@ -129,7 +129,20 @@ class Dispatcher:
|
||||
elif topic == UPDATE_CAMERA_ACTIVITY:
|
||||
self.camera_activity = payload
|
||||
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:
|
||||
self.publish(topic, payload, retain=False)
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ model_chache_dir = "/config/model_cache/rknn_cache/"
|
||||
class RknnDetectorConfig(BaseDetectorConfig):
|
||||
type: Literal[DETECTOR_KEY]
|
||||
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):
|
||||
@@ -36,7 +35,9 @@ class Rknn(DetectionApi):
|
||||
core_mask = 2**config.num_cores - 1
|
||||
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"]:
|
||||
config.model.model_type = model_props["model_type"]
|
||||
|
||||
@@ -209,7 +209,9 @@ class AudioEventMaintainer(threading.Thread):
|
||||
audio_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:
|
||||
continue
|
||||
|
||||
@@ -214,8 +214,7 @@ def parse_preset_hardware_acceleration_encode(
|
||||
|
||||
|
||||
PRESETS_INPUT = {
|
||||
"preset-http-jpeg-generic": _user_agent_args
|
||||
+ [
|
||||
"preset-http-jpeg-generic": [
|
||||
"-r",
|
||||
"{}",
|
||||
"-stream_loop",
|
||||
|
||||
@@ -395,7 +395,8 @@ class BirdsEyeFrameManager:
|
||||
[
|
||||
cam
|
||||
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"]
|
||||
< self.inactivity_threshold
|
||||
]
|
||||
|
||||
@@ -503,8 +503,15 @@ class ReviewSegmentMaintainer(threading.Thread):
|
||||
# temporarily make it so this event can not end
|
||||
current_segment.last_update = sys.maxsize
|
||||
elif manual_info["state"] == ManualEventState.end:
|
||||
self.indefinite_events[camera].pop(manual_info["event_id"])
|
||||
current_segment.last_update = manual_info["end_time"]
|
||||
event_id = manual_info["event_id"]
|
||||
|
||||
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:
|
||||
if topic == DetectionTypeEnum.video:
|
||||
self.check_if_new_segment(
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from json import JSONDecodeError
|
||||
from typing import Any, Optional
|
||||
|
||||
import psutil
|
||||
@@ -35,7 +36,7 @@ def get_latest_version(config: FrigateConfig) -> str:
|
||||
"https://api.github.com/repos/blakeblackshear/frigate/releases/latest",
|
||||
timeout=10,
|
||||
)
|
||||
except RequestException:
|
||||
except (RequestException, JSONDecodeError):
|
||||
return "unknown"
|
||||
|
||||
response = request.json()
|
||||
|
||||
@@ -90,7 +90,7 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
|
||||
# Remove UI fields
|
||||
if new_config.get("ui"):
|
||||
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"):
|
||||
del new_config["ui"]["live_mode"]
|
||||
|
||||
1
web/.node-version
Normal file
1
web/.node-version
Normal file
@@ -0,0 +1 @@
|
||||
20
|
||||
424
web/package-lock.json
generated
424
web/package-lock.json
generated
@@ -8,7 +8,7 @@
|
||||
"name": "web-new",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.0.5",
|
||||
"@cycjimmy/jsmpeg-player": "^6.1.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||
@@ -30,19 +30,19 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"apexcharts": "^3.50.0",
|
||||
"axios": "^1.7.2",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.7.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"hls.js": "^1.5.13",
|
||||
"hls.js": "^1.5.14",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"konva": "^9.3.13",
|
||||
"konva": "^9.3.14",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.407.0",
|
||||
"monaco-yaml": "^5.1.1",
|
||||
"monaco-yaml": "^5.2.2",
|
||||
"next-themes": "^0.3.0",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -54,7 +54,7 @@
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-icons": "^5.2.1",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"react-tracked": "^2.0.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
@@ -76,7 +76,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/jest-dom": "^6.4.6",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@@ -87,8 +87,8 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"@vitest/coverage-v8": "^2.0.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.2.0",
|
||||
@@ -98,15 +98,15 @@
|
||||
"eslint-plugin-vitest-globals": "^1.5.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"msw": "^2.3.0",
|
||||
"jsdom": "^24.1.1",
|
||||
"msw": "^2.3.5",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.3",
|
||||
"vitest": "^2.0.2"
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@aashutoshrathi/word-wrap": {
|
||||
@@ -233,10 +233,22 @@
|
||||
"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": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.0.5.tgz",
|
||||
"integrity": "sha512-bVNHQ7VN9ecKT5AI/6RC7zpW/y4ca68a9txeR5Wiin+jKpUn/7buMe+5NPub89A8NNeNnKPQfrD2+c76ch36mA=="
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@cycjimmy/jsmpeg-player/-/jsmpeg-player-6.1.1.tgz",
|
||||
"integrity": "sha512-YqT7U/3LxGu+6ikd+GGPe3rA2o6P4xrBHsWi/WRqv4n58h91fWDxS/3smneod+u6H2RnWlmXvZqx960dQ9T9gQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
@@ -986,15 +998,6 @@
|
||||
"@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": {
|
||||
"version": "0.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.29.1.tgz",
|
||||
@@ -2200,9 +2203,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.17.1.tgz",
|
||||
"integrity": "sha512-mCOMec4BKd6BRGBZeSnGiIgwsbLGp3yhVqAD8H+PxiRNEHgDpZb8J1TnrSDlg97t0ySKMQJTHCWBCmBpSmkF6Q==",
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.0.tgz",
|
||||
"integrity": "sha512-zDICCLKEwbVYTS6TjYaWtHXxkdoUvD/QXvyVZjGCsWz5vyH7aFeONlPffPdW+Y/t6KT0MgXb2Mfjun9YpWN1dA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -2692,15 +2695,10 @@
|
||||
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
|
||||
"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": {
|
||||
"version": "4.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.6.tgz",
|
||||
"integrity": "sha512-OpXEVoCKSS3lQqjx9GGGOapBeuW5eUboYHRlHP9urXPX25IKZ6AnP5ZRxtVf63iieUbsHxLn8NQ5Nlftc6yzAA==",
|
||||
"version": "4.17.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz",
|
||||
"integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2795,6 +2793,13 @@
|
||||
"integrity": "sha512-QIvDlGAKyF3YJbT3QZnfC+RIvV5noyDbi+ZJ5rkaSRqxCGrYJefgXm3leZAjtoQOutZe1hCXbAg+p89/Vj4HlQ==",
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz",
|
||||
@@ -3041,9 +3046,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.2.tgz",
|
||||
"integrity": "sha512-iA8eb4PMid3bMc++gfQSTvYE1QL//fC8pz+rKsTUDBFjdDiy/gH45hvpqyDu5K7FHhvgG0GNNCJzTMMSFKhoxg==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz",
|
||||
"integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3057,7 +3062,6 @@
|
||||
"magic-string": "^0.30.10",
|
||||
"magicast": "^0.3.4",
|
||||
"std-env": "^3.7.0",
|
||||
"strip-literal": "^2.1.0",
|
||||
"test-exclude": "^7.0.1",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
@@ -3065,18 +3069,18 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "2.0.2"
|
||||
"vitest": "2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.2.tgz",
|
||||
"integrity": "sha512-nKAvxBYqcDugYZ4nJvnm5OR8eDJdgWjk4XM9owQKUjzW70q0icGV2HVnQOyYsp906xJaBDUXw0+9EHw2T8e0mQ==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz",
|
||||
"integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "2.0.2",
|
||||
"@vitest/utils": "2.0.2",
|
||||
"@vitest/spy": "2.0.5",
|
||||
"@vitest/utils": "2.0.5",
|
||||
"chai": "^5.1.1",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
},
|
||||
@@ -3085,9 +3089,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.2.tgz",
|
||||
"integrity": "sha512-SBCyOXfGVvddRd9r2PwoVR0fonQjh9BMIcBMlSzbcNwFfGr6ZhOhvBzurjvi2F4ryut2HcqiFhNeDVGwru8tLg==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz",
|
||||
"integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3098,13 +3102,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.2.tgz",
|
||||
"integrity": "sha512-OCh437Vi8Wdbif1e0OvQcbfM3sW4s2lpmOjAE7qfLrpzJX2M7J1IQlNvEcb/fu6kaIB9n9n35wS0G2Q3en5kHg==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz",
|
||||
"integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "2.0.2",
|
||||
"@vitest/utils": "2.0.5",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
"funding": {
|
||||
@@ -3112,13 +3116,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.2.tgz",
|
||||
"integrity": "sha512-Yc2ewhhZhx+0f9cSUdfzPRcsM6PhIb+S43wxE7OG0kTxqgqzo8tHkXFuFlndXeDMp09G3sY/X5OAo/RfYydf1g==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz",
|
||||
"integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.0.2",
|
||||
"@vitest/pretty-format": "2.0.5",
|
||||
"magic-string": "^0.30.10",
|
||||
"pathe": "^1.1.2"
|
||||
},
|
||||
@@ -3127,9 +3131,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.2.tgz",
|
||||
"integrity": "sha512-MgwJ4AZtCgqyp2d7WcQVE8aNG5vQ9zu9qMPYQHjsld/QVsrvg78beNrXdO4HYkP0lDahCO3P4F27aagIag+SGQ==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz",
|
||||
"integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3140,13 +3144,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.2.tgz",
|
||||
"integrity": "sha512-pxCY1v7kmOCWYWjzc0zfjGTA3Wmn8PKnlPvSrsA643P1NHl1fOyXj2Q9SaNlrlFE+ivCsxM80Ov3AR82RmHCWQ==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz",
|
||||
"integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "2.0.2",
|
||||
"@vitest/pretty-format": "2.0.5",
|
||||
"estree-walker": "^3.0.3",
|
||||
"loupe": "^3.1.1",
|
||||
"tinyrainbow": "^1.2.0"
|
||||
@@ -3281,9 +3285,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/apexcharts": {
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.50.0.tgz",
|
||||
"integrity": "sha512-LJT1PNAm+NoIU3aogL2P+ViC0y/Cjik54FdzzGV54UNnGQLBoLe5ok3fxsJDTgyez45BGYT8gqNpYKqhdfy5sg==",
|
||||
"version": "3.52.0",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.52.0.tgz",
|
||||
"integrity": "sha512-7dg0ADKs8AA89iYMZMe2sFDG0XK5PfqllKV9N+i3hKHm3vEtdhwz8AlXGm+/b0nJ6jKiaXsqci5LfVxNhtB+dA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@yr/monotone-cubic-spline": "^1.0.3",
|
||||
@@ -3353,9 +3357,9 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.19",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz",
|
||||
"integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==",
|
||||
"version": "10.4.20",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
|
||||
"integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3371,12 +3375,13 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.23.0",
|
||||
"caniuse-lite": "^1.0.30001599",
|
||||
"browserslist": "^4.23.3",
|
||||
"caniuse-lite": "^1.0.30001646",
|
||||
"fraction.js": "^4.3.7",
|
||||
"normalize-range": "^0.1.2",
|
||||
"picocolors": "^1.0.0",
|
||||
"picocolors": "^1.0.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3390,9 +3395,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
|
||||
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.3.tgz",
|
||||
"integrity": "sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
@@ -3454,9 +3460,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.23.0",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz",
|
||||
"integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==",
|
||||
"version": "4.23.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
|
||||
"integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3472,11 +3478,12 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001587",
|
||||
"electron-to-chromium": "^1.4.668",
|
||||
"node-releases": "^2.0.14",
|
||||
"update-browserslist-db": "^1.0.13"
|
||||
"caniuse-lite": "^1.0.30001646",
|
||||
"electron-to-chromium": "^1.5.4",
|
||||
"node-releases": "^2.0.18",
|
||||
"update-browserslist-db": "^1.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -3529,9 +3536,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001599",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001599.tgz",
|
||||
"integrity": "sha512-LRAQHZ4yT1+f9LemSMeqdMpMxZcc4RMWdj4tiFe3G8tNkWK+E58g+/tzotb5cU6TbcVJLr4fySiAW7XmxQvZQA==",
|
||||
"version": "1.0.30001651",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz",
|
||||
"integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3546,7 +3553,8 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.1.1",
|
||||
@@ -4073,10 +4081,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.4.692",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.692.tgz",
|
||||
"integrity": "sha512-d5rZRka9n2Y3MkWRN74IoAsxR0HK3yaAt7T50e3iT9VZmCCQDT3geXUO5ZRMhDToa1pkCeQXuNo+0g+NfDOVPA==",
|
||||
"dev": true
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.5.tgz",
|
||||
"integrity": "sha512-QR7/A7ZkMS8tZuoftC/jfqNkZLQO779SSW3YuZHP4eXpj3EffGLFcB/Xu9AAZQzLccTiCV+EmUo3ha4mQ9wnlA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
@@ -4136,10 +4145,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
|
||||
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
|
||||
"integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -4859,9 +4869,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.5.13",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.13.tgz",
|
||||
"integrity": "sha512-xRgKo84nsC7clEvSfIdgn/Tc0NOT+d7vdiL/wvkLO+0k0juc26NRBPPG1SfB8pd5bHXIjMW/F5VM8VYYkOYYdw==",
|
||||
"version": "1.5.14",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.14.tgz",
|
||||
"integrity": "sha512-5wLiQ2kWJMui6oUslaq8PnPOv1vjuee5gTxjJD0DSsccY12OXtDT0h137UuqjczNeHzeEYR0ROZQibKNMr7Mzg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
@@ -4898,9 +4908,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz",
|
||||
"integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==",
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz",
|
||||
"integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5301,9 +5311,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "24.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.0.tgz",
|
||||
"integrity": "sha512-6gpM7pRXCwIOKxX47cgOyvyQDN/Eh0f1MeKySBV2xGdKtqJBLj8P25eY3EVCWo2mglDDzozR2r2MW4T+JiNUZA==",
|
||||
"version": "24.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.1.tgz",
|
||||
"integrity": "sha512-5O1wWV99Jhq4DV7rCLIoZ/UIhyQeDR7wHVyZAHAshbrvZsLs+Xzz7gtwnlJTJDjleiTKh54F4dXrX70vJQTyJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5313,11 +5323,11 @@
|
||||
"form-data": "^4.0.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"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",
|
||||
"nwsapi": "^2.2.10",
|
||||
"nwsapi": "^2.2.12",
|
||||
"parse5": "^7.1.2",
|
||||
"rrweb-cssom": "^0.7.0",
|
||||
"rrweb-cssom": "^0.7.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^4.1.4",
|
||||
@@ -5326,7 +5336,7 @@
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0",
|
||||
"ws": "^8.17.0",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5342,9 +5352,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/rrweb-cssom": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.0.tgz",
|
||||
"integrity": "sha512-KlSv0pm9kgQSRxXEMgtivPJ4h826YHsuob8pSHcfSZsSXGtvpEAie8S0AnXuObEJ7nhikOb4ahwxDm0H2yW17g==",
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
|
||||
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -5384,9 +5394,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/konva": {
|
||||
"version": "9.3.13",
|
||||
"resolved": "https://registry.npmjs.org/konva/-/konva-9.3.13.tgz",
|
||||
"integrity": "sha512-hs0ysHnqjK9noZ/rkfDNJINfbNhkXMgjgkJ8uc6vU0amu05mSDtRlukz5kKHOaSnWHA6miXcHJydvPABh18Y8A==",
|
||||
"version": "9.3.14",
|
||||
"resolved": "https://registry.npmjs.org/konva/-/konva-9.3.14.tgz",
|
||||
"integrity": "sha512-Gmm5lyikGYJyogKQA7Fy6dKkfNh350V6DwfZkid0RVrGYP2cfCsxuMxgF5etKeCv7NjXYpJxKqi1dYkIkX/dcA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
@@ -5642,9 +5652,10 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/monaco-languageserver-types": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.3.2.tgz",
|
||||
"integrity": "sha512-KiGVYK/DiX1pnacnOjGNlM85bhV3ZTyFlM+ce7B8+KpWCbF1XJVovu51YyuGfm+K7+K54mIpT4DFX16xmi+tYA==",
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-languageserver-types/-/monaco-languageserver-types-0.4.0.tgz",
|
||||
"integrity": "sha512-QQ3BZiU5LYkJElGncSNb5AKoJ/LCs6YBMCJMAz9EA7v+JaOdn3kx2cXpPTcZfKA5AEsR0vc97sAw+5mdNhVBmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"monaco-types": "^0.1.0",
|
||||
"vscode-languageserver-protocol": "^3.0.0",
|
||||
@@ -5669,6 +5680,7 @@
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-types/-/monaco-types-0.1.0.tgz",
|
||||
"integrity": "sha512-aWK7SN9hAqNYi0WosPoMjenMeXJjwCxDibOqWffyQ/qXdzB/86xshGQobRferfmNz7BSNQ8GB0MD0oby9/5fTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/remcohaszing"
|
||||
}
|
||||
@@ -5682,13 +5694,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-yaml": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.1.1.tgz",
|
||||
"integrity": "sha512-BuZ0/ZCGjrPNRzYMZ/MoxH8F/SdM+mATENXnpOhDYABi1Eh+QvxSszEct+ACSCarZiwLvy7m6yEF/pvW8XJkyQ==",
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/monaco-yaml/-/monaco-yaml-5.2.2.tgz",
|
||||
"integrity": "sha512-NWO/UhtJATlIsqwWPzK7YfbcIvPo3riFGsUkaGxNJoGiNPOvHD8vZ83ecqMQGkHPOpgHtSbe94uokE1AJvpbyQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"examples/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.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-types": "^0.1.0",
|
||||
"monaco-worker-manager": "^2.0.0",
|
||||
@@ -5727,17 +5742,17 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/msw": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/msw/-/msw-2.3.1.tgz",
|
||||
"integrity": "sha512-ocgvBCLn/5l3jpl1lssIb3cniuACJLoOfZu01e3n5dbJrpA5PeeWn28jCLgQDNt6d7QT8tF2fYRzm9JoEHtiig==",
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/msw/-/msw-2.3.5.tgz",
|
||||
"integrity": "sha512-+GUI4gX5YC5Bv33epBrD+BGdmDvBg2XGruiWnI3GbIbRmMMBeZ5gs3mJ51OWSGHgJKztZ8AtZeYMMNMVrje2/Q==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bundled-es-modules/cookie": "^2.0.0",
|
||||
"@bundled-es-modules/statuses": "^1.0.1",
|
||||
"@bundled-es-modules/tough-cookie": "^0.1.6",
|
||||
"@inquirer/confirm": "^3.0.0",
|
||||
"@mswjs/cookies": "^1.1.0",
|
||||
"@mswjs/interceptors": "^0.29.0",
|
||||
"@open-draft/until": "^2.1.0",
|
||||
"@types/cookie": "^0.6.0",
|
||||
@@ -5834,10 +5849,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
|
||||
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
|
||||
"dev": true
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
|
||||
"integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
@@ -5889,9 +5905,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.10",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz",
|
||||
"integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==",
|
||||
"version": "2.2.12",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz",
|
||||
"integrity": "sha512-qXDmcVlZV4XRtKFzddidpfVP4oMSGhga+xdMc25mv8kaLUHtgzCDhUxkrN8exkGdTlLNaXj7CV3GtON7zuGZ+w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -6165,9 +6181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.39",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
|
||||
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
|
||||
"version": "8.4.41",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz",
|
||||
"integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6741,12 +6757,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.24.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.24.1.tgz",
|
||||
"integrity": "sha512-PTXFXGK2pyXpHzVo3rR9H7ip4lSPZZc0bHG5CARmj65fTT6qG7sTngmb6lcYu1gf3y/8KxORoy9yn59pGpCnpg==",
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.0.tgz",
|
||||
"integrity": "sha512-wVQq0/iFYd3iZ9H2l3N3k4PL8EEHcb0XlU2Na8nEwmiXgIUElEH6gaJDtUQxJ+JFzmIXaQjfdpcGWaM6IoQGxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.17.1"
|
||||
"@remix-run/router": "1.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -6756,13 +6772,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.24.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.24.1.tgz",
|
||||
"integrity": "sha512-U19KtXqooqw967Vw0Qcn5cOvrX5Ejo9ORmOtJMzYWtCT4/WOfFLIZGGsVLxcd9UkBO0mSTZtXqhZBsWlHr7+Sg==",
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.0.tgz",
|
||||
"integrity": "sha512-RRGUIiDtLrkX3uYcFiCIxKFWMcWQGMojpYZfcstc63A1+sSnVgILGIm9gNUA6na3Fm1QuPGSBQH2EMbAZOnMsQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.17.1",
|
||||
"react-router": "6.24.1"
|
||||
"@remix-run/router": "1.19.0",
|
||||
"react-router": "6.26.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -7246,7 +7262,8 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
@@ -7300,7 +7317,8 @@
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
@@ -7426,26 +7444,6 @@
|
||||
"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": {
|
||||
"version": "3.34.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
|
||||
@@ -7648,9 +7646,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz",
|
||||
"integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==",
|
||||
"version": "3.4.9",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz",
|
||||
"integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
@@ -7931,9 +7929,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
|
||||
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
||||
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -7992,9 +7990,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
|
||||
"integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
|
||||
"integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8010,9 +8008,10 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escalade": "^3.1.1",
|
||||
"picocolors": "^1.0.0"
|
||||
"escalade": "^3.1.2",
|
||||
"picocolors": "^1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"update-browserslist-db": "cli.js"
|
||||
@@ -8120,14 +8119,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz",
|
||||
"integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==",
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.0.tgz",
|
||||
"integrity": "sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.39",
|
||||
"postcss": "^8.4.40",
|
||||
"rollup": "^4.13.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -8147,6 +8146,7 @@
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
@@ -8164,6 +8164,9 @@
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -8176,9 +8179,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.2.tgz",
|
||||
"integrity": "sha512-w4vkSz1Wo+NIQg8pjlEn0jQbcM/0D+xVaYjhw3cvarTanLLBh54oNiRbsT8PNK5GfuST0IlVXjsNRoNlqvY/fw==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz",
|
||||
"integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8207,19 +8210,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.2.tgz",
|
||||
"integrity": "sha512-WlpZ9neRIjNBIOQwBYfBSr0+of5ZCbxT2TVGKW4Lv0c8+srCFIiRdsP7U009t8mMn821HQ4XKgkx5dVWpyoyLw==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz",
|
||||
"integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"@vitest/expect": "2.0.2",
|
||||
"@vitest/pretty-format": "^2.0.2",
|
||||
"@vitest/runner": "2.0.2",
|
||||
"@vitest/snapshot": "2.0.2",
|
||||
"@vitest/spy": "2.0.2",
|
||||
"@vitest/utils": "2.0.2",
|
||||
"@vitest/expect": "2.0.5",
|
||||
"@vitest/pretty-format": "^2.0.5",
|
||||
"@vitest/runner": "2.0.5",
|
||||
"@vitest/snapshot": "2.0.5",
|
||||
"@vitest/spy": "2.0.5",
|
||||
"@vitest/utils": "2.0.5",
|
||||
"chai": "^5.1.1",
|
||||
"debug": "^4.3.5",
|
||||
"execa": "^8.0.1",
|
||||
@@ -8230,8 +8233,8 @@
|
||||
"tinypool": "^1.0.0",
|
||||
"tinyrainbow": "^1.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-node": "2.0.2",
|
||||
"why-is-node-running": "^2.2.2"
|
||||
"vite-node": "2.0.5",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
@@ -8245,8 +8248,8 @@
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"@vitest/browser": "2.0.2",
|
||||
"@vitest/ui": "2.0.2",
|
||||
"@vitest/browser": "2.0.5",
|
||||
"@vitest/ui": "2.0.5",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
@@ -8275,6 +8278,7 @@
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
@@ -8283,6 +8287,7 @@
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-jsonrpc": "8.2.0",
|
||||
"vscode-languageserver-types": "3.17.5"
|
||||
@@ -8296,12 +8301,14 @@
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"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": {
|
||||
"version": "3.0.8",
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
@@ -8386,10 +8393,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz",
|
||||
"integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
@@ -8440,9 +8448,9 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz",
|
||||
"integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.0.5",
|
||||
"@cycjimmy/jsmpeg-player": "^6.1.1",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||
@@ -36,19 +36,19 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"apexcharts": "^3.50.0",
|
||||
"axios": "^1.7.2",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.7.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"hls.js": "^1.5.13",
|
||||
"hls.js": "^1.5.14",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"immer": "^10.1.1",
|
||||
"konva": "^9.3.13",
|
||||
"konva": "^9.3.14",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.407.0",
|
||||
"monaco-yaml": "^5.1.1",
|
||||
"monaco-yaml": "^5.2.2",
|
||||
"next-themes": "^0.3.0",
|
||||
"nosleep.js": "^0.12.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -60,7 +60,7 @@
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-icons": "^5.2.1",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-router-dom": "^6.24.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"react-tracked": "^2.0.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
@@ -82,7 +82,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/jest-dom": "^6.4.6",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@@ -93,8 +93,8 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||
"@typescript-eslint/parser": "^7.5.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"@vitest/coverage-v8": "^2.0.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.2.0",
|
||||
@@ -104,14 +104,14 @@
|
||||
"eslint-plugin-vitest-globals": "^1.5.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"msw": "^2.3.0",
|
||||
"jsdom": "^24.1.1",
|
||||
"msw": "^2.3.5",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.3",
|
||||
"vitest": "^2.0.2"
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Suspense, lazy } from "react";
|
||||
import { Redirect } from "./components/navigation/Redirect";
|
||||
import { cn } from "./lib/utils";
|
||||
import { isPWA } from "./utils/isPWA";
|
||||
import { ADMIN_USERS } from "@/types/user";
|
||||
import useSWR from "swr";
|
||||
|
||||
const Live = lazy(() => import("@/pages/Live"));
|
||||
const Events = lazy(() => import("@/pages/Events"));
|
||||
@@ -22,6 +24,7 @@ const UIPlayground = lazy(() => import("@/pages/UIPlayground"));
|
||||
const Logs = lazy(() => import("@/pages/Logs"));
|
||||
|
||||
function App() {
|
||||
const { data: profile } = useSWR("profile");
|
||||
return (
|
||||
<Providers>
|
||||
<BrowserRouter basename={window.baseUrl}>
|
||||
@@ -47,9 +50,13 @@ function App() {
|
||||
<Route path="/export" element={<Exports />} />
|
||||
<Route path="/plus" element={<SubmitPlus />} />
|
||||
<Route path="/system" element={<System />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/config" element={<ConfigEditor />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
{ADMIN_USERS.includes(profile?.username) && (
|
||||
<>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/config" element={<ConfigEditor />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/playground" element={<UIPlayground />} />
|
||||
<Route path="*" element={<Redirect to="/" />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { baseUrl } from "./baseUrl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
FrigateCameraState,
|
||||
FrigateEvent,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
ToggleableSetting,
|
||||
} from "@/types/ws";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import useSWR from "swr";
|
||||
import { createContainer } from "react-tracked";
|
||||
import useDeepMemo from "@/hooks/use-deep-memo";
|
||||
|
||||
@@ -26,40 +24,50 @@ type WsState = {
|
||||
type useValueReturn = [WsState, (update: Update) => void];
|
||||
|
||||
function useValue(): useValueReturn {
|
||||
// basic config
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
|
||||
|
||||
// main state
|
||||
|
||||
const [hasCameraState, setHasCameraState] = useState(false);
|
||||
const [wsState, setWsState] = useState<WsState>({});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const cameraStates: WsState = {};
|
||||
|
||||
Object.keys(config.cameras).forEach((camera) => {
|
||||
const { name, record, detect, snapshots, audio, onvif } =
|
||||
config.cameras[camera];
|
||||
cameraStates[`${name}/recordings/state`] = record.enabled ? "ON" : "OFF";
|
||||
cameraStates[`${name}/detect/state`] = detect.enabled ? "ON" : "OFF";
|
||||
cameraStates[`${name}/snapshots/state`] = snapshots.enabled
|
||||
? "ON"
|
||||
: "OFF";
|
||||
cameraStates[`${name}/audio/state`] = audio.enabled ? "ON" : "OFF";
|
||||
cameraStates[`${name}/ptz_autotracker/state`] = onvif.autotracking.enabled
|
||||
Object.entries(cameraActivity).forEach(([name, state]) => {
|
||||
const { record, detect, snapshots, audio, autotracking } =
|
||||
// @ts-expect-error we know this is correct
|
||||
state["config"];
|
||||
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
||||
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
||||
cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
|
||||
cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
|
||||
cameraStates[`${name}/ptz_autotracker/state`] = autotracking
|
||||
? "ON"
|
||||
: "OFF";
|
||||
});
|
||||
|
||||
setWsState({ ...wsState, ...cameraStates });
|
||||
setHasCameraState(true);
|
||||
// we only want this to run initially when the config is loaded
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config]);
|
||||
}, [wsState]);
|
||||
|
||||
// ws handler
|
||||
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { baseUrl } from "../../api/baseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -43,7 +44,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await axios.post(
|
||||
"/api/login",
|
||||
"/login",
|
||||
{
|
||||
user: values.user,
|
||||
password: values.password,
|
||||
@@ -54,7 +55,7 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
},
|
||||
},
|
||||
);
|
||||
window.location.href = "/";
|
||||
window.location.href = baseUrl;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const err = error as AxiosError;
|
||||
|
||||
@@ -12,17 +12,21 @@ import { isCurrentHour } from "@/utils/dateUtil";
|
||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { useApiHost } from "@/api";
|
||||
import { isSafari } from "react-device-detect";
|
||||
import { isDesktop, isSafari } from "react-device-detect";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { Button } from "../ui/button";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
|
||||
type AnimatedEventCardProps = {
|
||||
event: ReviewSegment;
|
||||
selectedGroup?: string;
|
||||
updateEvents: () => void;
|
||||
};
|
||||
export function AnimatedEventCard({
|
||||
event,
|
||||
selectedGroup,
|
||||
updateEvents,
|
||||
}: AnimatedEventCardProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const apiHost = useApiHost();
|
||||
@@ -59,6 +63,7 @@ export function AnimatedEventCard({
|
||||
}, [visibilityListener]);
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// interaction
|
||||
|
||||
@@ -102,7 +107,26 @@ export function AnimatedEventCard({
|
||||
style={{
|
||||
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
|
||||
className="size-full cursor-pointer overflow-hidden rounded md:rounded-lg"
|
||||
onClick={onOpenReview}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function ExportCard({
|
||||
|
||||
const [editName, setEditName] = useState<{
|
||||
original: string;
|
||||
update: string;
|
||||
update?: string;
|
||||
}>();
|
||||
|
||||
const submitRename = useCallback(() => {
|
||||
@@ -52,7 +52,7 @@ export default function ExportCard({
|
||||
return;
|
||||
}
|
||||
|
||||
onRename(exportedRecording.id, editName.update);
|
||||
onRename(exportedRecording.id, editName.update ?? "");
|
||||
setEditName(undefined);
|
||||
}, [editName, exportedRecording, onRename, setEditName]);
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function ExportCard({
|
||||
modifiers.down &&
|
||||
!modifiers.repeat &&
|
||||
editName &&
|
||||
editName.update.length > 0
|
||||
(editName.update?.length ?? 0) > 0
|
||||
) {
|
||||
submitRename();
|
||||
}
|
||||
@@ -92,7 +92,11 @@ export default function ExportCard({
|
||||
className="mt-3"
|
||||
type="search"
|
||||
placeholder={editName?.original}
|
||||
value={editName?.update || editName?.original}
|
||||
value={
|
||||
editName?.update == undefined
|
||||
? editName?.original
|
||||
: editName?.update
|
||||
}
|
||||
onChange={(e) =>
|
||||
setEditName({
|
||||
original: editName.original ?? "",
|
||||
@@ -124,13 +128,27 @@ export default function ExportCard({
|
||||
onMouseLeave={isDesktop ? () => setHovered(false) : undefined}
|
||||
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">
|
||||
{!exportedRecording.in_progress && (
|
||||
<a
|
||||
className="z-20"
|
||||
download
|
||||
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
|
||||
>
|
||||
@@ -145,7 +163,7 @@ export default function ExportCard({
|
||||
onClick={() =>
|
||||
setEditName({
|
||||
original: exportedRecording.name,
|
||||
update: "",
|
||||
update: undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -167,7 +185,7 @@ export default function ExportCard({
|
||||
|
||||
{!exportedRecording.in_progress && (
|
||||
<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"
|
||||
onClick={() => {
|
||||
onSelect(exportedRecording);
|
||||
@@ -176,27 +194,12 @@ export default function ExportCard({
|
||||
<FaPlay />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{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" />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<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">
|
||||
{exportedRecording.name.replaceAll("_", " ")}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { isDesktop, isIOS, isSafari } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
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 ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
||||
import { FaCompactDisc } from "react-icons/fa";
|
||||
@@ -18,9 +18,22 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "../ui/context-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog";
|
||||
import { Drawer, DrawerContent } from "../ui/drawer";
|
||||
import axios from "axios";
|
||||
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 = {
|
||||
event: ReviewSegment;
|
||||
@@ -46,6 +59,8 @@ export default function ReviewCard({
|
||||
);
|
||||
|
||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const bypassDialogRef = useRef(false);
|
||||
|
||||
const onMarkAsReviewed = useCallback(async () => {
|
||||
await axios.post(`reviews/viewed`, { ids: [event.id] });
|
||||
@@ -92,6 +107,18 @@ export default function ReviewCard({
|
||||
setOptionsOpen(false);
|
||||
}, [event]);
|
||||
|
||||
useKeyboardListener(["Shift"], (_, modifiers) => {
|
||||
bypassDialogRef.current = modifiers.shift;
|
||||
});
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (bypassDialogRef.current) {
|
||||
onDelete();
|
||||
} else {
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
}, [bypassDialogRef, onDelete]);
|
||||
|
||||
const content = (
|
||||
<div
|
||||
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-evenly gap-1">
|
||||
{event.data.objects.map((object) => {
|
||||
return getIconForLabel(object, "size-3 text-white");
|
||||
})}
|
||||
{event.data.audio.map((audio) => {
|
||||
return getIconForLabel(audio, "size-3 text-white");
|
||||
})}
|
||||
<div className="font-extra-light text-xs">{formattedDate}</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-evenly gap-1">
|
||||
<>
|
||||
{event.data.objects.map((object) => {
|
||||
return getIconForLabel(
|
||||
object,
|
||||
"size-3 text-primary dark:text-white",
|
||||
);
|
||||
})}
|
||||
{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
|
||||
className="text-xs text-muted-foreground"
|
||||
time={event.start_time * 1000}
|
||||
@@ -152,71 +207,129 @@ export default function ReviewCard({
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
|
||||
onClick={onExport}
|
||||
>
|
||||
<FaCompactDisc className="text-secondary-foreground" />
|
||||
<div className="text-primary">Export</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
{!event.has_been_reviewed && (
|
||||
<>
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
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>
|
||||
<ContextMenu key={event.id}>
|
||||
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
|
||||
onClick={onMarkAsReviewed}
|
||||
onClick={onExport}
|
||||
>
|
||||
<FaCircleCheck className="text-secondary-foreground" />
|
||||
<div className="text-primary">Mark as reviewed</div>
|
||||
<FaCompactDisc className="text-secondary-foreground" />
|
||||
<div className="text-primary">Export</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<HiTrash className="text-secondary-foreground" />
|
||||
<div className="text-primary">Delete</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
{!event.has_been_reviewed && (
|
||||
<ContextMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
|
||||
onClick={onMarkAsReviewed}
|
||||
>
|
||||
<FaCircleCheck className="text-secondary-foreground" />
|
||||
<div className="text-primary">Mark as reviewed</div>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<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 (
|
||||
<Drawer open={optionsOpen} onOpenChange={setOptionsOpen}>
|
||||
{content}
|
||||
<DrawerContent>
|
||||
<div
|
||||
className="flex w-full items-center justify-start gap-2 p-2"
|
||||
onClick={onExport}
|
||||
>
|
||||
<FaCompactDisc className="text-secondary-foreground" />
|
||||
<div className="text-primary">Export</div>
|
||||
</div>
|
||||
{!event.has_been_reviewed && (
|
||||
<>
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
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
|
||||
className="flex w-full items-center justify-start gap-2 p-2"
|
||||
onClick={onMarkAsReviewed}
|
||||
onClick={onExport}
|
||||
>
|
||||
<FaCircleCheck className="text-secondary-foreground" />
|
||||
<div className="text-primary">Mark as reviewed</div>
|
||||
<FaCompactDisc className="text-secondary-foreground" />
|
||||
<div className="text-primary">Export</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex w-full items-center justify-start gap-2 p-2"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<HiTrash className="text-secondary-foreground" />
|
||||
<div className="text-primary">Delete</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{!event.has_been_reviewed && (
|
||||
<div
|
||||
className="flex w-full items-center justify-start gap-2 p-2"
|
||||
onClick={onMarkAsReviewed}
|
||||
>
|
||||
<FaCircleCheck className="text-secondary-foreground" />
|
||||
<div className="text-primary">Mark as reviewed</div>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -551,6 +551,14 @@ export function CameraGroupEdit({
|
||||
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", {
|
||||
message: "Invalid camera group name.",
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { Button } from "../ui/button";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { FaCompactDisc } from "react-icons/fa";
|
||||
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 = {
|
||||
selectedReviews: string[];
|
||||
@@ -34,49 +45,94 @@ export default function ReviewActionGroup({
|
||||
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 (
|
||||
<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
|
||||
<>
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
|
||||
</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 className="flex items-center gap-1 md:gap-2">
|
||||
{selectedReviews.length == 1 && (
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{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
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onExport(selectedReviews[0]);
|
||||
onClearSelected();
|
||||
}}
|
||||
onClick={onMarkAsReviewed}
|
||||
>
|
||||
<FaCompactDisc className="text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">Export</div>}
|
||||
<FaCircleCheck className="text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">Mark as reviewed</div>}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
onClick={onMarkAsReviewed}
|
||||
>
|
||||
<FaCircleCheck className="text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">Mark as reviewed</div>}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<HiTrash className="text-secondary-foreground" />
|
||||
{isDesktop && <div className="text-primary">Delete</div>}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<HiTrash className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{bypassDialog ? "Delete Now" : "Delete"}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,11 @@ export default function ReviewFilterGroup({
|
||||
|
||||
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 || {}),
|
||||
zones: Object.values(allZones || {}),
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { baseUrl } from "../../api/baseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
@@ -26,7 +27,7 @@ type AccountSettingsProps = {
|
||||
export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||
const { data: profile } = useSWR("profile");
|
||||
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 Trigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||
|
||||
@@ -68,6 +68,8 @@ import {
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import useSWR from "swr";
|
||||
import { ADMIN_USERS } from "@/types/user";
|
||||
|
||||
type GeneralSettingsProps = {
|
||||
className?: string;
|
||||
@@ -80,6 +82,8 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
|
||||
const { send: sendRestart } = useRestart();
|
||||
|
||||
const { data: profile } = useSWR("profile");
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval: NodeJS.Timeout;
|
||||
|
||||
@@ -139,8 +143,18 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
</Tooltip>
|
||||
</Trigger>
|
||||
<Content
|
||||
style={
|
||||
isDesktop
|
||||
? {
|
||||
maxHeight:
|
||||
"var(--radix-dropdown-menu-content-available-height)",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
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">
|
||||
@@ -159,48 +173,58 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<span>System metrics</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Link to="/logs">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuList className="mr-2 size-4" />
|
||||
<span>System logs</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
{ADMIN_USERS.includes(profile?.username) && (
|
||||
<Link to="/logs">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuList className="mr-2 size-4" />
|
||||
<span>System logs</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Configuration
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{ADMIN_USERS.includes(profile?.username) && (
|
||||
<>
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Configuration
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuGroup>
|
||||
<Link to="/settings">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuSettings className="mr-2 size-4" />
|
||||
<span>Settings</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Link to="/config">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuPenSquare className="mr-2 size-4" />
|
||||
<span>Configuration editor</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
{ADMIN_USERS.includes(profile?.username) && (
|
||||
<>
|
||||
<Link to="/settings">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuSettings className="mr-2 size-4" />
|
||||
<span>Settings</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<Link to="/config">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex w-full items-center p-2 text-sm"
|
||||
}
|
||||
>
|
||||
<LuPenSquare className="mr-2 size-4" />
|
||||
<span>Configuration editor</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
Appearance
|
||||
</DropdownMenuLabel>
|
||||
@@ -348,16 +372,24 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<span>GitHub</span>
|
||||
</MenuItem>
|
||||
</a>
|
||||
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setRestartDialogOpen(true)}
|
||||
>
|
||||
<LuRotateCw className="mr-2 size-4" />
|
||||
<span>Restart Frigate</span>
|
||||
</MenuItem>
|
||||
{ADMIN_USERS.includes(profile?.username) && (
|
||||
<>
|
||||
<DropdownMenuSeparator
|
||||
className={isDesktop ? "mt-3" : "mt-1"}
|
||||
/>
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
? "cursor-pointer"
|
||||
: "flex items-center p-2 text-sm"
|
||||
}
|
||||
onClick={() => setRestartDialogOpen(true)}
|
||||
>
|
||||
<LuRotateCw className="mr-2 size-4" />
|
||||
<span>Restart Frigate</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Content>
|
||||
</Container>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
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 VideoControls from "./VideoControls";
|
||||
import { VideoResolutionType } from "@/types/live";
|
||||
@@ -33,6 +33,7 @@ type HlsVideoPlayerProps = {
|
||||
visible: boolean;
|
||||
currentSource: string;
|
||||
hotKeys: boolean;
|
||||
supportsFullscreen: boolean;
|
||||
fullscreen: boolean;
|
||||
onClipEnded?: () => void;
|
||||
onPlayerLoaded?: () => void;
|
||||
@@ -49,6 +50,7 @@ export default function HlsVideoPlayer({
|
||||
visible,
|
||||
currentSource,
|
||||
hotKeys,
|
||||
supportsFullscreen,
|
||||
fullscreen,
|
||||
onClipEnded,
|
||||
onPlayerLoaded,
|
||||
@@ -180,7 +182,7 @@ export default function HlsVideoPlayer({
|
||||
seek: true,
|
||||
playbackRate: true,
|
||||
plusUpload: config?.plus?.enabled == true,
|
||||
fullscreen: !isIOS,
|
||||
fullscreen: supportsFullscreen,
|
||||
}}
|
||||
setControlsOpen={setControlsOpen}
|
||||
setMuted={(muted) => setMuted(muted, true)}
|
||||
|
||||
@@ -13,19 +13,19 @@ import {
|
||||
LivePlayerMode,
|
||||
VideoResolutionType,
|
||||
} from "@/types/live";
|
||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import Chip from "../indicators/Chip";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TbExclamationCircle } from "react-icons/tb";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
|
||||
type LivePlayerProps = {
|
||||
cameraRef?: (ref: HTMLDivElement | null) => void;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
className?: string;
|
||||
cameraConfig: CameraConfig;
|
||||
preferredLiveMode?: LivePlayerMode;
|
||||
preferredLiveMode: LivePlayerMode;
|
||||
showStillWithoutActivity?: boolean;
|
||||
windowVisible?: boolean;
|
||||
playAudio?: boolean;
|
||||
@@ -36,6 +36,7 @@ type LivePlayerProps = {
|
||||
onClick?: () => void;
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onError?: (error: LivePlayerError) => void;
|
||||
onResetLiveMode?: () => void;
|
||||
};
|
||||
|
||||
export default function LivePlayer({
|
||||
@@ -54,6 +55,7 @@ export default function LivePlayer({
|
||||
onClick,
|
||||
setFullResolution,
|
||||
onError,
|
||||
onResetLiveMode,
|
||||
}: LivePlayerProps) {
|
||||
const internalContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
// camera activity
|
||||
@@ -70,8 +72,6 @@ export default function LivePlayer({
|
||||
|
||||
// camera live state
|
||||
|
||||
const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode);
|
||||
|
||||
const [liveReady, setLiveReady] = useState(false);
|
||||
|
||||
const liveReadyRef = useRef(liveReady);
|
||||
@@ -91,6 +91,7 @@ export default function LivePlayer({
|
||||
const timer = setTimeout(() => {
|
||||
if (liveReadyRef.current && !cameraActiveRef.current) {
|
||||
setLiveReady(false);
|
||||
onResetLiveMode?.();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -152,7 +153,7 @@ export default function LivePlayer({
|
||||
let player;
|
||||
if (!autoLive) {
|
||||
player = null;
|
||||
} else if (liveMode == "webrtc") {
|
||||
} else if (preferredLiveMode == "webrtc") {
|
||||
player = (
|
||||
<WebRtcPlayer
|
||||
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
|
||||
@@ -166,7 +167,7 @@ export default function LivePlayer({
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
} else if (liveMode == "mse") {
|
||||
} else if (preferredLiveMode == "mse") {
|
||||
if ("MediaSource" in window || "ManagedMediaSource" in window) {
|
||||
player = (
|
||||
<MSEPlayer
|
||||
@@ -187,7 +188,7 @@ export default function LivePlayer({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (liveMode == "jsmpeg") {
|
||||
} else if (preferredLiveMode == "jsmpeg") {
|
||||
if (cameraActive || !showStillWithoutActivity || liveReady) {
|
||||
player = (
|
||||
<JSMpegPlayer
|
||||
@@ -258,23 +259,22 @@ export default function LivePlayer({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
<TooltipContent className="capitalize">
|
||||
{[
|
||||
...new Set([
|
||||
...(objects || []).map(({ label, sub_label }) =>
|
||||
label.endsWith("verified") ? sub_label : label,
|
||||
),
|
||||
]),
|
||||
]
|
||||
.filter(
|
||||
(label) =>
|
||||
label !== undefined && !label.includes("-verified"),
|
||||
)
|
||||
.map((label) => capitalizeFirstLetter(label))
|
||||
.sort()
|
||||
.join(", ")
|
||||
.replaceAll("-verified", "")}
|
||||
</TooltipContent>
|
||||
<TooltipPortal>
|
||||
<TooltipContent className="capitalize">
|
||||
{[
|
||||
...new Set([
|
||||
...(objects || []).map(({ label, sub_label }) =>
|
||||
label.endsWith("verified") ? sub_label : label,
|
||||
),
|
||||
]),
|
||||
]
|
||||
.filter((label) => label?.includes("-verified") == false)
|
||||
.map((label) => capitalizeFirstLetter(label))
|
||||
.sort()
|
||||
.join(", ")
|
||||
.replaceAll("-verified", "")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,7 @@ function MSEPlayer({
|
||||
onError,
|
||||
}: MSEPlayerProps) {
|
||||
const RECONNECT_TIMEOUT: number = 10000;
|
||||
const BUFFERING_COOLDOWN_TIMEOUT: number = 5000;
|
||||
|
||||
const CODECS: string[] = [
|
||||
"avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
|
||||
@@ -46,6 +47,11 @@ function MSEPlayer({
|
||||
|
||||
const visibilityCheck: boolean = !pip;
|
||||
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 [connectTS, setConnectTS] = useState<number>(0);
|
||||
@@ -133,6 +139,13 @@ function MSEPlayer({
|
||||
}
|
||||
}, [bufferTimeout]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
// don't let the user pause the live stream
|
||||
if (isPlaying && playbackEnabled) {
|
||||
videoRef.current?.play();
|
||||
}
|
||||
}, [isPlaying, playbackEnabled]);
|
||||
|
||||
const onOpen = () => {
|
||||
setWsState(WebSocket.OPEN);
|
||||
|
||||
@@ -193,6 +206,7 @@ function MSEPlayer({
|
||||
|
||||
const onMse = () => {
|
||||
if ("ManagedMediaSource" in window) {
|
||||
// safari
|
||||
const MediaSource = window.ManagedMediaSource;
|
||||
|
||||
msRef.current?.addEventListener(
|
||||
@@ -224,6 +238,7 @@ function MSEPlayer({
|
||||
videoRef.current.srcObject = msRef.current;
|
||||
}
|
||||
} else {
|
||||
// non safari
|
||||
msRef.current?.addEventListener(
|
||||
"sourceopen",
|
||||
() => {
|
||||
@@ -247,15 +262,35 @@ function MSEPlayer({
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
videoRef.current!.src = URL.createObjectURL(msRef.current!);
|
||||
videoRef.current!.srcObject = null;
|
||||
if (videoRef.current && msRef.current) {
|
||||
videoRef.current.src = URL.createObjectURL(msRef.current);
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
}
|
||||
play();
|
||||
|
||||
onmessageRef.current["mse"] = (msg) => {
|
||||
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", () => {
|
||||
if (sb.updating) return;
|
||||
|
||||
@@ -302,6 +337,137 @@ function MSEPlayer({
|
||||
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(() => {
|
||||
if (!playbackEnabled) {
|
||||
return;
|
||||
@@ -386,45 +552,11 @@ function MSEPlayer({
|
||||
handleLoadedMetadata?.();
|
||||
onPlaying?.();
|
||||
setIsPlaying(true);
|
||||
lastJumpTimeRef.current = Date.now();
|
||||
}}
|
||||
muted={!audioEnabled}
|
||||
onPause={() => videoRef.current?.play()}
|
||||
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),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onPause={handlePause}
|
||||
onProgress={onProgress}
|
||||
onError={(e) => {
|
||||
if (
|
||||
// @ts-expect-error code does exist
|
||||
|
||||
@@ -16,6 +16,10 @@ import { isAndroid, isChrome, isMobile } from "react-device-detect";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getPreviewForTimeRange,
|
||||
usePreviewForTimeRange,
|
||||
} from "@/hooks/use-camera-previews";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
className?: string;
|
||||
@@ -39,15 +43,11 @@ export default function PreviewPlayer({
|
||||
onClick,
|
||||
}: PreviewPlayerProps) {
|
||||
const [currentHourFrame, setCurrentHourFrame] = useState<string>();
|
||||
|
||||
const currentPreview = useMemo(() => {
|
||||
return cameraPreviews.find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
Math.round(preview.start) >= timeRange.after &&
|
||||
Math.floor(preview.end) <= timeRange.before,
|
||||
);
|
||||
}, [cameraPreviews, camera, timeRange]);
|
||||
const currentPreview = usePreviewForTimeRange(
|
||||
cameraPreviews,
|
||||
camera,
|
||||
timeRange,
|
||||
);
|
||||
|
||||
if (currentPreview) {
|
||||
return (
|
||||
@@ -246,12 +246,7 @@ function PreviewVideoPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = cameraPreviews.find(
|
||||
(preview) =>
|
||||
preview.camera == camera &&
|
||||
Math.round(preview.start) >= timeRange.after &&
|
||||
Math.floor(preview.end) <= timeRange.before,
|
||||
);
|
||||
const preview = getPreviewForTimeRange(cameraPreviews, camera, timeRange);
|
||||
|
||||
if (preview != currentPreview) {
|
||||
controller.newPreviewLoaded = false;
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import ImageLoadingIndicator from "../indicators/ImageLoadingIndicator";
|
||||
import useContextMenu from "@/hooks/use-contextmenu";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { TimelineScrubMode, TimeRange } from "@/types/timeline";
|
||||
import { NoThumbSlider } from "../ui/slider";
|
||||
import { PREVIEW_FPS, PREVIEW_PADDING } from "@/types/preview";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
@@ -414,7 +414,7 @@ export function VideoPreview({
|
||||
|
||||
if (isSafari || (isFirefox && isMobile)) {
|
||||
playerRef.current.pause();
|
||||
setManualPlayback(true);
|
||||
setPlaybackMode("compat");
|
||||
} else {
|
||||
playerRef.current.currentTime = playerStartTime;
|
||||
playerRef.current.playbackRate = PREVIEW_FPS;
|
||||
@@ -453,9 +453,9 @@ export function VideoPreview({
|
||||
setReviewed();
|
||||
|
||||
if (loop && playerRef.current) {
|
||||
if (manualPlayback) {
|
||||
setManualPlayback(false);
|
||||
setTimeout(() => setManualPlayback(true), 100);
|
||||
if (playbackMode != "auto") {
|
||||
setPlaybackMode("auto");
|
||||
setTimeout(() => setPlaybackMode("compat"), 100);
|
||||
}
|
||||
|
||||
playerRef.current.currentTime = playerStartTime;
|
||||
@@ -472,7 +472,7 @@ export function VideoPreview({
|
||||
playerRef.current?.pause();
|
||||
}
|
||||
|
||||
setManualPlayback(false);
|
||||
setPlaybackMode("auto");
|
||||
setProgress(100.0);
|
||||
} else {
|
||||
setProgress(playerPercent);
|
||||
@@ -486,9 +486,10 @@ export function VideoPreview({
|
||||
// safari is incapable of playing at a speed > 2x
|
||||
// so manual seeking is required on iOS
|
||||
|
||||
const [manualPlayback, setManualPlayback] = useState(false);
|
||||
const [playbackMode, setPlaybackMode] = useState<TimelineScrubMode>("auto");
|
||||
|
||||
useEffect(() => {
|
||||
if (!manualPlayback || !playerRef.current) {
|
||||
if (playbackMode != "compat" || !playerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -503,10 +504,14 @@ export function VideoPreview({
|
||||
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [manualPlayback, playerRef]);
|
||||
}, [playbackMode, playerRef]);
|
||||
|
||||
// user interaction
|
||||
|
||||
useEffect(() => {
|
||||
setIgnoreClick(playbackMode != "auto" && playbackMode != "compat");
|
||||
}, [playbackMode, setIgnoreClick]);
|
||||
|
||||
const onManualSeek = useCallback(
|
||||
(values: number[]) => {
|
||||
const value = values[0];
|
||||
@@ -515,14 +520,8 @@ export function VideoPreview({
|
||||
return;
|
||||
}
|
||||
|
||||
if (manualPlayback) {
|
||||
setManualPlayback(false);
|
||||
setIgnoreClick(true);
|
||||
}
|
||||
|
||||
if (playerRef.current.paused == false) {
|
||||
playerRef.current.pause();
|
||||
setIgnoreClick(true);
|
||||
}
|
||||
|
||||
if (setReviewed) {
|
||||
@@ -536,27 +535,21 @@ export function VideoPreview({
|
||||
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
manualPlayback,
|
||||
playerDuration,
|
||||
playerRef,
|
||||
playerStartTime,
|
||||
setIgnoreClick,
|
||||
],
|
||||
[playerDuration, playerRef, playerStartTime, setIgnoreClick],
|
||||
);
|
||||
|
||||
const onStopManualSeek = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
setIgnoreClick(false);
|
||||
setHoverTimeout(undefined);
|
||||
|
||||
if (isSafari || (isFirefox && isMobile)) {
|
||||
setManualPlayback(true);
|
||||
setPlaybackMode("compat");
|
||||
} else {
|
||||
setPlaybackMode("auto");
|
||||
playerRef.current?.play();
|
||||
}
|
||||
}, 500);
|
||||
}, [playerRef, setIgnoreClick]);
|
||||
}, [playerRef]);
|
||||
|
||||
const onProgressHover = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -572,10 +565,8 @@ export function VideoPreview({
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout);
|
||||
}
|
||||
|
||||
setHoverTimeout(setTimeout(() => onStopManualSeek(), 500));
|
||||
},
|
||||
[sliderRef, hoverTimeout, onManualSeek, onStopManualSeek, setHoverTimeout],
|
||||
[sliderRef, hoverTimeout, onManualSeek],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -597,14 +588,37 @@ export function VideoPreview({
|
||||
{showProgress && (
|
||||
<NoThumbSlider
|
||||
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]}
|
||||
onValueChange={onManualSeek}
|
||||
onValueChange={(event) => {
|
||||
setPlaybackMode("drag");
|
||||
onManualSeek(event);
|
||||
}}
|
||||
onValueCommit={onStopManualSeek}
|
||||
min={0}
|
||||
step={1}
|
||||
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>
|
||||
@@ -642,7 +656,8 @@ export function InProgressPreview({
|
||||
}/frames`,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
const [manualFrame, setManualFrame] = useState(false);
|
||||
|
||||
const [playbackMode, setPlaybackMode] = useState<TimelineScrubMode>("auto");
|
||||
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout>();
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
@@ -655,7 +670,7 @@ export function InProgressPreview({
|
||||
onTimeUpdate(review.start_time - PREVIEW_PADDING + key);
|
||||
}
|
||||
|
||||
if (manualFrame) {
|
||||
if (playbackMode != "auto") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -692,19 +707,18 @@ export function InProgressPreview({
|
||||
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, manualFrame, previewFrames]);
|
||||
}, [key, playbackMode, previewFrames]);
|
||||
|
||||
// user interaction
|
||||
|
||||
useEffect(() => {
|
||||
setIgnoreClick(playbackMode != "auto");
|
||||
}, [playbackMode, setIgnoreClick]);
|
||||
|
||||
const onManualSeek = useCallback(
|
||||
(values: number[]) => {
|
||||
const value = values[0];
|
||||
|
||||
if (!manualFrame) {
|
||||
setManualFrame(true);
|
||||
setIgnoreClick(true);
|
||||
}
|
||||
|
||||
if (!review.has_been_reviewed) {
|
||||
setReviewed(review.id);
|
||||
}
|
||||
@@ -714,19 +728,18 @@ export function InProgressPreview({
|
||||
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[manualFrame, setIgnoreClick, setManualFrame, setKey],
|
||||
[setIgnoreClick, setKey],
|
||||
);
|
||||
|
||||
const onStopManualSeek = useCallback(
|
||||
(values: number[]) => {
|
||||
const value = values[0];
|
||||
setTimeout(() => {
|
||||
setIgnoreClick(false);
|
||||
setManualFrame(false);
|
||||
setPlaybackMode("auto");
|
||||
setKey(value - 1);
|
||||
}, 500);
|
||||
},
|
||||
[setManualFrame, setIgnoreClick],
|
||||
[setPlaybackMode],
|
||||
);
|
||||
|
||||
const onProgressHover = useCallback(
|
||||
@@ -744,17 +757,8 @@ export function InProgressPreview({
|
||||
if (hoverTimeout) {
|
||||
clearTimeout(hoverTimeout);
|
||||
}
|
||||
|
||||
setHoverTimeout(setTimeout(() => onStopManualSeek(progress), 500));
|
||||
},
|
||||
[
|
||||
sliderRef,
|
||||
hoverTimeout,
|
||||
previewFrames,
|
||||
onManualSeek,
|
||||
onStopManualSeek,
|
||||
setHoverTimeout,
|
||||
],
|
||||
[sliderRef, hoverTimeout, previewFrames, onManualSeek],
|
||||
);
|
||||
|
||||
if (!previewFrames || previewFrames.length == 0) {
|
||||
@@ -776,14 +780,46 @@ export function InProgressPreview({
|
||||
{showProgress && (
|
||||
<NoThumbSlider
|
||||
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]}
|
||||
onValueChange={onManualSeek}
|
||||
onValueChange={(event) => {
|
||||
setPlaybackMode("drag");
|
||||
onManualSeek(event);
|
||||
}}
|
||||
onValueCommit={onStopManualSeek}
|
||||
min={0}
|
||||
step={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>
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function VideoControls({
|
||||
}, [volume, muted]);
|
||||
|
||||
const onKeyboardShortcut = useCallback(
|
||||
(key: string, modifiers: KeyModifiers) => {
|
||||
(key: string | null, modifiers: KeyModifiers) => {
|
||||
if (!modifiers.down) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type DynamicVideoPlayerProps = {
|
||||
startTimestamp?: number;
|
||||
isScrubbing: boolean;
|
||||
hotKeys: boolean;
|
||||
supportsFullscreen: boolean;
|
||||
fullscreen: boolean;
|
||||
onControllerReady: (controller: DynamicVideoController) => void;
|
||||
onTimestampUpdate?: (timestamp: number) => void;
|
||||
@@ -40,6 +41,7 @@ export default function DynamicVideoPlayer({
|
||||
startTimestamp,
|
||||
isScrubbing,
|
||||
hotKeys,
|
||||
supportsFullscreen,
|
||||
fullscreen,
|
||||
onControllerReady,
|
||||
onTimestampUpdate,
|
||||
@@ -167,7 +169,11 @@ export default function DynamicVideoPlayer({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller || !recordings) {
|
||||
if (!controller || !recordings?.length) {
|
||||
if (recordings?.length == 0) {
|
||||
setNoRecording(true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,6 +203,7 @@ export default function DynamicVideoPlayer({
|
||||
visible={!(isScrubbing || isLoading)}
|
||||
currentSource={source}
|
||||
hotKeys={hotKeys}
|
||||
supportsFullscreen={supportsFullscreen}
|
||||
fullscreen={fullscreen}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onPlayerLoaded={onPlayerLoaded}
|
||||
|
||||
@@ -114,6 +114,29 @@ export function PolygonCanvas({
|
||||
const mousePos = stage.getPointerPosition() ?? { x: 0, y: 0 };
|
||||
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 (
|
||||
activePolygon.points.length >= 3 &&
|
||||
intersection?.getClassName() == "Circle" &&
|
||||
@@ -236,6 +259,9 @@ export function PolygonCanvas({
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleMouseDown}
|
||||
onMouseOver={handleStageMouseOver}
|
||||
onContextMenu={(e) => {
|
||||
e.evt.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Layer>
|
||||
<Image
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function PolygonEditControls({
|
||||
<MdUndo className="text-secondary-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Undo</TooltipContent>
|
||||
<TooltipContent>Remove last point</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -106,6 +106,14 @@ export default function ZoneEditPane({
|
||||
{
|
||||
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
|
||||
.number()
|
||||
|
||||
@@ -1,49 +1,65 @@
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { usePersistence } from "./use-persistence";
|
||||
import { LivePlayerMode } from "@/types/live";
|
||||
|
||||
export default function useCameraLiveMode(
|
||||
cameraConfig: CameraConfig,
|
||||
preferredMode?: LivePlayerMode,
|
||||
): LivePlayerMode | undefined {
|
||||
cameras: CameraConfig[],
|
||||
windowVisible: boolean,
|
||||
) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
||||
[key: string]: LivePlayerMode;
|
||||
}>({});
|
||||
|
||||
const restreamEnabled = useMemo(() => {
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!cameras) return;
|
||||
|
||||
return (
|
||||
cameraConfig &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(
|
||||
cameraConfig.live.stream_name,
|
||||
)
|
||||
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 },
|
||||
);
|
||||
}, [config, cameraConfig]);
|
||||
const defaultLiveMode = useMemo<LivePlayerMode | undefined>(() => {
|
||||
if (config) {
|
||||
if (restreamEnabled) {
|
||||
return preferredMode || "mse";
|
||||
}
|
||||
|
||||
return "jsmpeg";
|
||||
}
|
||||
setPreferredLiveModes(newPreferredLiveModes);
|
||||
}, [cameras, config, windowVisible]);
|
||||
|
||||
return undefined;
|
||||
}, [config, preferredMode, restreamEnabled]);
|
||||
const [viewSource] = usePersistence<LivePlayerMode>(
|
||||
`${cameraConfig.name}-source`,
|
||||
defaultLiveMode,
|
||||
const resetPreferredLiveMode = useCallback(
|
||||
(cameraName: string) => {
|
||||
const mseSupported =
|
||||
"MediaSource" in window || "ManagedMediaSource" in window;
|
||||
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 (
|
||||
restreamEnabled &&
|
||||
(preferredMode == "mse" || preferredMode == "webrtc")
|
||||
) {
|
||||
return preferredMode;
|
||||
} else {
|
||||
return viewSource;
|
||||
}
|
||||
return { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Preview } from "@/types/preview";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
|
||||
type OptionalCameraPreviewProps = {
|
||||
@@ -8,7 +8,6 @@ type OptionalCameraPreviewProps = {
|
||||
autoRefresh?: boolean;
|
||||
fetchPreviews?: boolean;
|
||||
};
|
||||
|
||||
export function useCameraPreviews(
|
||||
initialTimeRange: TimeRange,
|
||||
{
|
||||
@@ -32,3 +31,33 @@ export function useCameraPreviews(
|
||||
|
||||
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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RefObject, useCallback, useEffect, useState } from "react";
|
||||
import { RefObject, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import nosleep from "nosleep.js";
|
||||
|
||||
const NoSleep = new nosleep();
|
||||
@@ -147,5 +147,31 @@ export function useFullscreen<T extends HTMLElement = HTMLElement>(
|
||||
}
|
||||
}, [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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ export type KeyModifiers = {
|
||||
down: boolean;
|
||||
repeat: boolean;
|
||||
ctrl: boolean;
|
||||
shift: boolean;
|
||||
};
|
||||
|
||||
export default function useKeyboardListener(
|
||||
keys: string[],
|
||||
listener: (key: string, modifiers: KeyModifiers) => void,
|
||||
listener: (key: string | null, modifiers: KeyModifiers) => void,
|
||||
) {
|
||||
const keyDownListener = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@@ -16,13 +17,18 @@ export default function useKeyboardListener(
|
||||
return;
|
||||
}
|
||||
|
||||
const modifiers = {
|
||||
down: true,
|
||||
repeat: e.repeat,
|
||||
ctrl: e.ctrlKey || e.metaKey,
|
||||
shift: e.shiftKey,
|
||||
};
|
||||
|
||||
if (keys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
listener(e.key, {
|
||||
down: true,
|
||||
repeat: e.repeat,
|
||||
ctrl: e.ctrlKey || e.metaKey,
|
||||
});
|
||||
listener(e.key, modifiers);
|
||||
} else if (e.key === "Shift" || e.key === "Control" || e.key === "Meta") {
|
||||
listener(null, modifiers);
|
||||
}
|
||||
},
|
||||
[keys, listener],
|
||||
@@ -34,9 +40,18 @@ export default function useKeyboardListener(
|
||||
return;
|
||||
}
|
||||
|
||||
const modifiers = {
|
||||
down: false,
|
||||
repeat: false,
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
};
|
||||
|
||||
if (keys.includes(e.key)) {
|
||||
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],
|
||||
|
||||
39
web/src/hooks/use-session-persistence.ts
Normal file
39
web/src/hooks/use-session-persistence.ts
Normal 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];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import LoginPage from "@/pages/LoginPage.tsx";
|
||||
import "@/api";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -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(() => {
|
||||
if (config && modelRef.current) {
|
||||
modelRef.current.setValue(config);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [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) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function Events() {
|
||||
|
||||
// review paging
|
||||
|
||||
const [beforeTs, setBeforeTs] = useState(Date.now() / 1000);
|
||||
const [beforeTs, setBeforeTs] = useState(Math.ceil(Date.now() / 1000));
|
||||
const last24Hours = useMemo(() => {
|
||||
return { before: beforeTs, after: getHoursAgo(24) };
|
||||
}, [beforeTs]);
|
||||
@@ -111,7 +111,7 @@ export default function Events() {
|
||||
}
|
||||
|
||||
return {
|
||||
before: Math.floor(reviewSearchParams["before"]),
|
||||
before: Math.ceil(reviewSearchParams["before"]),
|
||||
after: Math.floor(reviewSearchParams["after"]),
|
||||
};
|
||||
}, [last24Hours, reviewSearchParams]);
|
||||
@@ -416,6 +416,7 @@ export default function Events() {
|
||||
if (selectedReviewData) {
|
||||
return (
|
||||
<RecordingView
|
||||
key={selectedTimeRange.before}
|
||||
startCamera={selectedReviewData.camera}
|
||||
startTime={selectedReviewData.start_time}
|
||||
allCameras={selectedReviewData.allCameras}
|
||||
@@ -455,5 +456,5 @@ export default function Events() {
|
||||
function getHoursAgo(hours: number): number {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() - hours);
|
||||
return now.getTime() / 1000;
|
||||
return Math.ceil(now.getTime() / 1000);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ function Live() {
|
||||
|
||||
const mainRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { fullscreen, toggleFullscreen } = useFullscreen(mainRef);
|
||||
const { fullscreen, toggleFullscreen, supportsFullScreen } =
|
||||
useFullscreen(mainRef);
|
||||
|
||||
// document title
|
||||
|
||||
@@ -100,6 +101,7 @@ function Live() {
|
||||
<div className="size-full" ref={mainRef}>
|
||||
{selectedCameraName === "birdseye" ? (
|
||||
<LiveBirdseyeView
|
||||
supportsFullscreen={supportsFullScreen}
|
||||
fullscreen={fullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
@@ -107,6 +109,7 @@ function Live() {
|
||||
<LiveCameraView
|
||||
config={config}
|
||||
camera={selectedCamera}
|
||||
supportsFullscreen={supportsFullScreen}
|
||||
fullscreen={fullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
|
||||
@@ -286,6 +286,7 @@ function Logs() {
|
||||
key={item}
|
||||
className={`flex items-center justify-between gap-2 ${logService == item ? "" : "text-muted-foreground"}`}
|
||||
value={item}
|
||||
data-nav-item={item}
|
||||
aria-label={`Select ${item}`}
|
||||
>
|
||||
<div className="capitalize">{item}</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ import { LuFolderX } from "react-icons/lu";
|
||||
import { PiSlidersHorizontalFill } from "react-icons/pi";
|
||||
import useSWR from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
||||
|
||||
const API_LIMIT = 100;
|
||||
|
||||
@@ -241,103 +242,136 @@ export default function SubmitPlus() {
|
||||
<PlusSortSelector selectedSort={sort} setSelectedSort={setSort} />
|
||||
</div>
|
||||
<div className="no-scrollbar flex size-full flex-1 flex-wrap content-start gap-2 overflow-y-auto md:gap-4">
|
||||
{isValidating ? (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
) : events?.length === 0 ? (
|
||||
<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">
|
||||
<LuFolderX className="size-16" />
|
||||
No snapshots found
|
||||
</div>
|
||||
{!events?.length ? (
|
||||
<>
|
||||
{isValidating ? (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
) : (
|
||||
<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">
|
||||
<LuFolderX className="size-16" />
|
||||
No snapshots found
|
||||
</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-2xl lg:max-w-3xl xl:max-w-4xl">
|
||||
<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>
|
||||
<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)}
|
||||
<>
|
||||
<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 }}
|
||||
>
|
||||
This is a {upload?.label}
|
||||
</Button>
|
||||
<Button
|
||||
className="text-white"
|
||||
variant="destructive"
|
||||
onClick={() => onSubmitToPlus(true)}
|
||||
<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)}
|
||||
>
|
||||
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 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>
|
||||
<img
|
||||
className="aspect-video h-full rounded-lg object-contain md:rounded-2xl"
|
||||
src={`${baseUrl}api/events/${event.id}/snapshot.jpg`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!isValidating && !isDone && <div ref={lastEventRef} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!isDone && isValidating ? (
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<div ref={lastEventRef} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -477,12 +511,16 @@ function PlusFilterGroup({
|
||||
className="w-12"
|
||||
inputMode="numeric"
|
||||
value={Math.round((currentScoreRange?.at(0) ?? 0.5) * 100)}
|
||||
onChange={(e) =>
|
||||
setCurrentScoreRange([
|
||||
parseInt(e.target.value) / 100.0,
|
||||
currentScoreRange?.at(1) ?? 1.0,
|
||||
])
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (value) {
|
||||
setCurrentScoreRange([
|
||||
parseInt(value) / 100.0,
|
||||
currentScoreRange?.at(1) ?? 1.0,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DualThumbSlider
|
||||
className="w-full"
|
||||
@@ -496,12 +534,16 @@ function PlusFilterGroup({
|
||||
className="w-12"
|
||||
inputMode="numeric"
|
||||
value={Math.round((currentScoreRange?.at(1) ?? 1.0) * 100)}
|
||||
onChange={(e) =>
|
||||
setCurrentScoreRange([
|
||||
currentScoreRange?.at(0) ?? 0.5,
|
||||
parseInt(e.target.value) / 100.0,
|
||||
])
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (value) {
|
||||
setCurrentScoreRange([
|
||||
currentScoreRange?.at(0) ?? 0.5,
|
||||
parseInt(value) / 100.0,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -298,7 +298,12 @@ export interface FrigateConfig {
|
||||
retry_interval: number;
|
||||
};
|
||||
|
||||
go2rtc: Record<string, unknown>;
|
||||
go2rtc: {
|
||||
streams: string[];
|
||||
webrtc: {
|
||||
candidates: string[];
|
||||
};
|
||||
};
|
||||
|
||||
camera_groups: { [groupName: string]: CameraGroupConfig };
|
||||
|
||||
|
||||
@@ -26,3 +26,5 @@ export type Timeline = {
|
||||
export type TimeRange = { before: number; after: number };
|
||||
|
||||
export type TimelineType = "timeline" | "events";
|
||||
|
||||
export type TimelineScrubMode = "auto" | "drag" | "hover" | "compat";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type User = {
|
||||
username: string;
|
||||
};
|
||||
|
||||
export const ADMIN_USERS: string[] = ["admin", "cking91977", "akadmin"];
|
||||
|
||||
@@ -395,6 +395,7 @@ export default function EventView({
|
||||
markAllItemsAsReviewed={markAllItemsAsReviewed}
|
||||
onSelectReview={onSelectReview}
|
||||
onSelectAllReviews={onSelectAllReviews}
|
||||
setSelectedReviews={setSelectedReviews}
|
||||
pullLatestData={pullLatestData}
|
||||
/>
|
||||
)}
|
||||
@@ -437,6 +438,7 @@ type DetectionReviewProps = {
|
||||
markAllItemsAsReviewed: (currentItems: ReviewSegment[]) => void;
|
||||
onSelectReview: (review: ReviewSegment, ctrl: boolean) => void;
|
||||
onSelectAllReviews: () => void;
|
||||
setSelectedReviews: (reviewIds: string[]) => void;
|
||||
pullLatestData: () => void;
|
||||
};
|
||||
function DetectionReview({
|
||||
@@ -455,6 +457,7 @@ function DetectionReview({
|
||||
markAllItemsAsReviewed,
|
||||
onSelectReview,
|
||||
onSelectAllReviews,
|
||||
setSelectedReviews,
|
||||
pullLatestData,
|
||||
}: DetectionReviewProps) {
|
||||
const reviewTimelineRef = useRef<HTMLDivElement>(null);
|
||||
@@ -603,7 +606,7 @@ function DetectionReview({
|
||||
|
||||
// keyboard
|
||||
|
||||
useKeyboardListener(["a"], (key, modifiers) => {
|
||||
useKeyboardListener(["a", "r"], (key, modifiers) => {
|
||||
if (modifiers.repeat || !modifiers.down) {
|
||||
return;
|
||||
}
|
||||
@@ -611,6 +614,16 @@ function DetectionReview({
|
||||
if (key == "a" && modifiers.ctrl) {
|
||||
onSelectAllReviews();
|
||||
}
|
||||
|
||||
if (key == "r" && selectedReviews.length > 0) {
|
||||
currentItems?.forEach((item) => {
|
||||
if (selectedReviews.includes(item.id)) {
|
||||
item.has_been_reviewed = true;
|
||||
markItemAsReviewed(item);
|
||||
}
|
||||
});
|
||||
setSelectedReviews([]);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -692,6 +705,7 @@ function DetectionReview({
|
||||
className="text-white"
|
||||
variant="select"
|
||||
onClick={() => {
|
||||
setSelectedReviews([]);
|
||||
markAllItemsAsReviewed(currentItems ?? []);
|
||||
}}
|
||||
>
|
||||
@@ -1057,7 +1071,7 @@ function MotionReview({
|
||||
|
||||
setScrubbing(scrubbing);
|
||||
}}
|
||||
dense={isMobile}
|
||||
dense={isMobileOnly}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="size-full" />
|
||||
|
||||
@@ -84,7 +84,11 @@ export function RecordingView({
|
||||
const previewRowRef = useRef<HTMLDivElement | null>(null);
|
||||
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(
|
||||
() => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [],
|
||||
@@ -107,8 +111,10 @@ export function RecordingView({
|
||||
return chunk.after <= startTime && chunk.before >= startTime;
|
||||
}),
|
||||
);
|
||||
const currentTimeRange = useMemo(
|
||||
() => chunkedTimeRange[selectedRangeIdx],
|
||||
const currentTimeRange = useMemo<TimeRange>(
|
||||
() =>
|
||||
chunkedTimeRange[selectedRangeIdx] ??
|
||||
chunkedTimeRange[chunkedTimeRange.length - 1],
|
||||
[selectedRangeIdx, chunkedTimeRange],
|
||||
);
|
||||
const reviewFilterList = useMemo(() => {
|
||||
@@ -198,6 +204,10 @@ export function RecordingView({
|
||||
|
||||
const manuallySetCurrentTime = useCallback(
|
||||
(time: number) => {
|
||||
if (!currentTimeRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentTime(time);
|
||||
|
||||
if (currentTimeRange.after <= time && currentTimeRange.before >= time) {
|
||||
@@ -247,7 +257,8 @@ export function RecordingView({
|
||||
|
||||
// fullscreen
|
||||
|
||||
const { fullscreen, toggleFullscreen } = useFullscreen(mainLayoutRef);
|
||||
const { fullscreen, toggleFullscreen, supportsFullScreen } =
|
||||
useFullscreen(mainLayoutRef);
|
||||
|
||||
// layout
|
||||
|
||||
@@ -507,7 +518,7 @@ export function RecordingView({
|
||||
"pt-2 portrait:w-full",
|
||||
mainCameraAspect == "wide"
|
||||
? "aspect-wide landscape:w-full"
|
||||
: "aspect-video landscape:h-[94%]",
|
||||
: "aspect-video landscape:h-[94%] landscape:xl:h-[65%]",
|
||||
),
|
||||
)}
|
||||
style={{
|
||||
@@ -539,6 +550,7 @@ export function RecordingView({
|
||||
mainControllerRef.current = controller;
|
||||
}}
|
||||
isScrubbing={scrubbing || exportMode == "timeline"}
|
||||
supportsFullscreen={supportsFullScreen}
|
||||
setFullResolution={setFullResolution}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
containerRef={mainLayoutRef}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||
|
||||
type DraggableGridLayoutProps = {
|
||||
cameras: CameraConfig[];
|
||||
@@ -75,36 +76,8 @@ export default function DraggableGridLayout({
|
||||
|
||||
// preferred live modes per camera
|
||||
|
||||
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
||||
[key: string]: LivePlayerMode;
|
||||
}>({});
|
||||
|
||||
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 { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode } =
|
||||
useCameraLiveMode(cameras, windowVisible);
|
||||
|
||||
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
||||
|
||||
@@ -477,6 +450,7 @@ export default function DraggableGridLayout({
|
||||
return newModes;
|
||||
});
|
||||
}}
|
||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||
>
|
||||
{isEditMode && showCircles && <CornerCircles />}
|
||||
</LivePlayerGridItem>
|
||||
@@ -635,6 +609,7 @@ type LivePlayerGridItemProps = {
|
||||
preferredLiveMode: LivePlayerMode;
|
||||
onClick: () => void;
|
||||
onError: (e: LivePlayerError) => void;
|
||||
onResetLiveMode: () => void;
|
||||
};
|
||||
|
||||
const LivePlayerGridItem = React.forwardRef<
|
||||
@@ -655,6 +630,7 @@ const LivePlayerGridItem = React.forwardRef<
|
||||
preferredLiveMode,
|
||||
onClick,
|
||||
onError,
|
||||
onResetLiveMode,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -676,6 +652,7 @@ const LivePlayerGridItem = React.forwardRef<
|
||||
preferredLiveMode={preferredLiveMode}
|
||||
onClick={onClick}
|
||||
onError={onError}
|
||||
onResetLiveMode={onResetLiveMode}
|
||||
containerRef={ref as React.RefObject<HTMLDivElement>}
|
||||
/>
|
||||
{children}
|
||||
|
||||
@@ -22,11 +22,13 @@ import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
||||
import useSWR from "swr";
|
||||
|
||||
type LiveBirdseyeViewProps = {
|
||||
supportsFullscreen: boolean;
|
||||
fullscreen: boolean;
|
||||
toggleFullscreen: () => void;
|
||||
};
|
||||
|
||||
export default function LiveBirdseyeView({
|
||||
supportsFullscreen,
|
||||
fullscreen,
|
||||
toggleFullscreen,
|
||||
}: LiveBirdseyeViewProps) {
|
||||
@@ -155,14 +157,16 @@ export default function LiveBirdseyeView({
|
||||
<div
|
||||
className={`mr-1 flex flex-row items-center gap-2 *:rounded-lg ${isMobile ? "landscape:flex-col" : ""}`}
|
||||
>
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={fullscreen ? FaCompress : FaExpand}
|
||||
isActive={fullscreen}
|
||||
title={fullscreen ? "Close" : "Fullscreen"}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
{supportsFullscreen && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={fullscreen ? FaCompress : FaExpand}
|
||||
isActive={fullscreen}
|
||||
title={fullscreen ? "Close" : "Fullscreen"}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
)}
|
||||
{!isIOS && !isFirefox && config.birdseye.restream && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
|
||||
@@ -78,16 +78,19 @@ import { useNavigate } from "react-router-dom";
|
||||
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
|
||||
import useSWR from "swr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSessionPersistence } from "@/hooks/use-session-persistence";
|
||||
|
||||
type LiveCameraViewProps = {
|
||||
config?: FrigateConfig;
|
||||
camera: CameraConfig;
|
||||
supportsFullscreen: boolean;
|
||||
fullscreen: boolean;
|
||||
toggleFullscreen: () => void;
|
||||
};
|
||||
export default function LiveCameraView({
|
||||
config,
|
||||
camera,
|
||||
supportsFullscreen,
|
||||
fullscreen,
|
||||
toggleFullscreen,
|
||||
}: LiveCameraViewProps) {
|
||||
@@ -194,7 +197,7 @@ export default function LiveCameraView({
|
||||
|
||||
// playback state
|
||||
|
||||
const [audio, setAudio] = useState(false);
|
||||
const [audio, setAudio] = useSessionPersistence("liveAudio", false);
|
||||
const [mic, setMic] = useState(false);
|
||||
const [webRTC, setWebRTC] = useState(false);
|
||||
const [pip, setPip] = useState(false);
|
||||
@@ -226,6 +229,10 @@ export default function LiveCameraView({
|
||||
return "webrtc";
|
||||
}
|
||||
|
||||
if (!isRestreamed) {
|
||||
return "jsmpeg";
|
||||
}
|
||||
|
||||
return "mse";
|
||||
}, [lowBandwidth, mic, webRTC, isRestreamed]);
|
||||
|
||||
@@ -285,14 +292,23 @@ export default function LiveCameraView({
|
||||
}
|
||||
}, [fullscreen, isPortrait, cameraAspectRatio, containerAspectRatio]);
|
||||
|
||||
const handleError = useCallback((e: LivePlayerError) => {
|
||||
if (e == "mse-decode") {
|
||||
setWebRTC(true);
|
||||
} else {
|
||||
setWebRTC(false);
|
||||
setLowBandwidth(true);
|
||||
}
|
||||
}, []);
|
||||
const handleError = useCallback(
|
||||
(e: LivePlayerError) => {
|
||||
if (e) {
|
||||
if (
|
||||
!webRTC &&
|
||||
config &&
|
||||
config.go2rtc?.webrtc?.candidates?.length > 0
|
||||
) {
|
||||
setWebRTC(true);
|
||||
} else {
|
||||
setWebRTC(false);
|
||||
setLowBandwidth(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[config, webRTC],
|
||||
);
|
||||
|
||||
return (
|
||||
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
|
||||
@@ -362,7 +378,7 @@ export default function LiveCameraView({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!isIOS && (
|
||||
{supportsFullscreen && (
|
||||
<CameraFeatureToggle
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
@@ -404,13 +420,14 @@ export default function LiveCameraView({
|
||||
className="p-2 md:p-0"
|
||||
variant={fullscreen ? "overlay" : "primary"}
|
||||
Icon={audio ? GiSpeaker : GiSpeakerOff}
|
||||
isActive={audio}
|
||||
isActive={audio ?? false}
|
||||
title={`${audio ? "Disable" : "Enable"} Camera Audio`}
|
||||
onClick={() => setAudio(!audio)}
|
||||
/>
|
||||
)}
|
||||
<FrigateCameraFeatures
|
||||
camera={camera.name}
|
||||
recordingEnabled={camera.record.enabled_in_config}
|
||||
audioDetectEnabled={camera.audio.enabled_in_config}
|
||||
autotrackingEnabled={
|
||||
camera.onvif.autotracking.enabled_in_config
|
||||
@@ -669,12 +686,14 @@ function PtzControlPanel({
|
||||
|
||||
type FrigateCameraFeaturesProps = {
|
||||
camera: string;
|
||||
recordingEnabled: boolean;
|
||||
audioDetectEnabled: boolean;
|
||||
autotrackingEnabled: boolean;
|
||||
fullscreen: boolean;
|
||||
};
|
||||
function FrigateCameraFeatures({
|
||||
camera,
|
||||
recordingEnabled,
|
||||
audioDetectEnabled,
|
||||
autotrackingEnabled,
|
||||
fullscreen,
|
||||
@@ -763,11 +782,15 @@ function FrigateCameraFeatures({
|
||||
isChecked={detectState == "ON"}
|
||||
onCheckedChange={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
|
||||
/>
|
||||
<FilterSwitch
|
||||
label="Recording"
|
||||
isChecked={recordState == "ON"}
|
||||
onCheckedChange={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
|
||||
/>
|
||||
{recordingEnabled && (
|
||||
<FilterSwitch
|
||||
label="Recording"
|
||||
isChecked={recordState == "ON"}
|
||||
onCheckedChange={() =>
|
||||
sendRecord(recordState == "ON" ? "OFF" : "ON")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FilterSwitch
|
||||
label="Snapshots"
|
||||
isChecked={snapshotState == "ON"}
|
||||
|
||||
@@ -28,8 +28,9 @@ import DraggableGridLayout from "./DraggableGridLayout";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
import { LuLayoutDashboard } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LivePlayerError, LivePlayerMode } from "@/types/live";
|
||||
import { LivePlayerError } from "@/types/live";
|
||||
import { FaCompress, FaExpand } from "react-icons/fa";
|
||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
|
||||
type LiveDashboardViewProps = {
|
||||
@@ -129,9 +130,6 @@ export default function LiveDashboardView({
|
||||
// camera live views
|
||||
|
||||
const [autoLiveView] = usePersistence("autoLiveView", true);
|
||||
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
||||
[key: string]: LivePlayerMode;
|
||||
}>({});
|
||||
|
||||
const [{ height: containerHeight }] = useResizeObserver(containerRef);
|
||||
|
||||
@@ -186,32 +184,8 @@ export default function LiveDashboardView({
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode } =
|
||||
useCameraLiveMode(cameras, windowVisible);
|
||||
|
||||
const cameraRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
@@ -315,6 +289,7 @@ export default function LiveDashboardView({
|
||||
key={event.id}
|
||||
event={event}
|
||||
selectedGroup={cameraGroup}
|
||||
updateEvents={updateEvents}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -380,6 +355,7 @@ export default function LiveDashboardView({
|
||||
autoLive={autoLiveView}
|
||||
onClick={() => onSelectCamera(camera.name)}
|
||||
onError={(e) => handleError(camera.name, e)}
|
||||
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -163,7 +163,7 @@ export default function GeneralMetrics({
|
||||
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) {
|
||||
series[key].data.push({
|
||||
@@ -304,7 +304,7 @@ export default function GeneralMetrics({
|
||||
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) {
|
||||
series[key].data.push({
|
||||
@@ -338,10 +338,14 @@ export default function GeneralMetrics({
|
||||
series[key] = { name: key, data: [] };
|
||||
}
|
||||
|
||||
series[key].data.push({
|
||||
x: statsIdx + 1,
|
||||
y: stats.cpu_usages[procStats.pid.toString()].mem,
|
||||
});
|
||||
const data = stats.cpu_usages[procStats.pid.toString()]?.mem;
|
||||
|
||||
if (data) {
|
||||
series[key].data.push({
|
||||
x: statsIdx + 1,
|
||||
y: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user