Merge remote-tracking branch 'upstream/dev' into dev

# Conflicts:
#	web/src/views/live/DraggableGridLayout.tsx
#	web/src/views/live/LiveDashboardView.tsx
This commit is contained in:
kensand 2024-08-25 09:55:15 -04:00
commit a19e745cdf
63 changed files with 1789 additions and 896 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
default_target: local default_target: local
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1) COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
VERSION = 0.14.0 VERSION = 0.14.1
IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate IMAGE_REPO ?= ghcr.io/blakeblackshear/frigate
GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) GITHUB_REF_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
CURRENT_UID := $(shell id -u) CURRENT_UID := $(shell id -u)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

424
web/package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@ -12,17 +12,21 @@ import { isCurrentHour } from "@/utils/dateUtil";
import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { isSafari } from "react-device-detect"; import { isDesktop, isSafari } from "react-device-detect";
import { usePersistence } from "@/hooks/use-persistence"; import { usePersistence } from "@/hooks/use-persistence";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6";
type AnimatedEventCardProps = { type AnimatedEventCardProps = {
event: ReviewSegment; event: ReviewSegment;
selectedGroup?: string; selectedGroup?: string;
updateEvents: () => void;
}; };
export function AnimatedEventCard({ export function AnimatedEventCard({
event, event,
selectedGroup, selectedGroup,
updateEvents,
}: AnimatedEventCardProps) { }: AnimatedEventCardProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const apiHost = useApiHost(); const apiHost = useApiHost();
@ -59,6 +63,7 @@ export function AnimatedEventCard({
}, [visibilityListener]); }, [visibilityListener]);
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const [isHovered, setIsHovered] = useState(false);
// interaction // interaction
@ -102,7 +107,26 @@ export function AnimatedEventCard({
style={{ style={{
aspectRatio: aspectRatio, aspectRatio: aspectRatio,
}} }}
onMouseEnter={isDesktop ? () => setIsHovered(true) : undefined}
onMouseLeave={isDesktop ? () => setIsHovered(false) : undefined}
> >
{isHovered && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="absolute right-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
size="xs"
onClick={async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] });
updateEvents();
}}
>
<FaCircleCheck className="size-3 text-white" />
</Button>
</TooltipTrigger>
<TooltipContent>Mark as Reviewed</TooltipContent>
</Tooltip>
)}
<div <div
className="size-full cursor-pointer overflow-hidden rounded md:rounded-lg" className="size-full cursor-pointer overflow-hidden rounded md:rounded-lg"
onClick={onOpenReview} onClick={onOpenReview}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -139,8 +139,18 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
</Tooltip> </Tooltip>
</Trigger> </Trigger>
<Content <Content
style={
isDesktop
? {
maxHeight:
"var(--radix-dropdown-menu-content-available-height)",
}
: {}
}
className={ className={
isDesktop ? "mr-5 w-72" : "max-h-[75dvh] overflow-hidden p-2" isDesktop
? "scrollbar-container mr-5 w-72 overflow-y-auto"
: "max-h-[75dvh] overflow-hidden p-2"
} }
> >
<div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container w-full flex-col overflow-y-auto overflow-x-hidden">

View File

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

View File

@ -13,7 +13,6 @@ import {
LivePlayerMode, LivePlayerMode,
VideoResolutionType, VideoResolutionType,
} from "@/types/live"; } from "@/types/live";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import Chip from "../indicators/Chip"; import Chip from "../indicators/Chip";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil";
@ -25,7 +24,7 @@ type LivePlayerProps = {
containerRef?: React.MutableRefObject<HTMLDivElement | null>; containerRef?: React.MutableRefObject<HTMLDivElement | null>;
className?: string; className?: string;
cameraConfig: CameraConfig; cameraConfig: CameraConfig;
preferredLiveMode?: LivePlayerMode; preferredLiveMode: LivePlayerMode;
showStillWithoutActivity?: boolean; showStillWithoutActivity?: boolean;
windowVisible?: boolean; windowVisible?: boolean;
playAudio?: boolean; playAudio?: boolean;
@ -36,6 +35,7 @@ type LivePlayerProps = {
onClick?: () => void; onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void; onError?: (error: LivePlayerError) => void;
onResetLiveMode?: () => void;
}; };
export default function LivePlayer({ export default function LivePlayer({
@ -54,6 +54,7 @@ export default function LivePlayer({
onClick, onClick,
setFullResolution, setFullResolution,
onError, onError,
onResetLiveMode,
}: LivePlayerProps) { }: LivePlayerProps) {
const internalContainerRef = useRef<HTMLDivElement | null>(null); const internalContainerRef = useRef<HTMLDivElement | null>(null);
// camera activity // camera activity
@ -70,8 +71,6 @@ export default function LivePlayer({
// camera live state // camera live state
const liveMode = useCameraLiveMode(cameraConfig, preferredLiveMode);
const [liveReady, setLiveReady] = useState(false); const [liveReady, setLiveReady] = useState(false);
const liveReadyRef = useRef(liveReady); const liveReadyRef = useRef(liveReady);
@ -91,6 +90,7 @@ export default function LivePlayer({
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (liveReadyRef.current && !cameraActiveRef.current) { if (liveReadyRef.current && !cameraActiveRef.current) {
setLiveReady(false); setLiveReady(false);
onResetLiveMode?.();
} }
}, 500); }, 500);
@ -152,7 +152,7 @@ export default function LivePlayer({
let player; let player;
if (!autoLive) { if (!autoLive) {
player = null; player = null;
} else if (liveMode == "webrtc") { } else if (preferredLiveMode == "webrtc") {
player = ( player = (
<WebRtcPlayer <WebRtcPlayer
className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`} className={`size-full rounded-lg md:rounded-2xl ${liveReady ? "" : "hidden"}`}
@ -166,7 +166,7 @@ export default function LivePlayer({
onError={onError} onError={onError}
/> />
); );
} else if (liveMode == "mse") { } else if (preferredLiveMode == "mse") {
if ("MediaSource" in window || "ManagedMediaSource" in window) { if ("MediaSource" in window || "ManagedMediaSource" in window) {
player = ( player = (
<MSEPlayer <MSEPlayer
@ -187,7 +187,7 @@ export default function LivePlayer({
</div> </div>
); );
} }
} else if (liveMode == "jsmpeg") { } else if (preferredLiveMode == "jsmpeg") {
if (cameraActive || !showStillWithoutActivity || liveReady) { if (cameraActive || !showStillWithoutActivity || liveReady) {
player = ( player = (
<JSMpegPlayer <JSMpegPlayer
@ -266,10 +266,7 @@ export default function LivePlayer({
), ),
]), ]),
] ]
.filter( .filter((label) => label?.includes("-verified") == false)
(label) =>
label !== undefined && !label.includes("-verified"),
)
.map((label) => capitalizeFirstLetter(label)) .map((label) => capitalizeFirstLetter(label))
.sort() .sort()
.join(", ") .join(", ")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -84,7 +84,11 @@ export function RecordingView({
const previewRowRef = useRef<HTMLDivElement | null>(null); const previewRowRef = useRef<HTMLDivElement | null>(null);
const previewRefs = useRef<{ [camera: string]: PreviewController }>({}); const previewRefs = useRef<{ [camera: string]: PreviewController }>({});
const [playbackStart, setPlaybackStart] = useState(startTime); const [playbackStart, setPlaybackStart] = useState(
startTime >= timeRange.after && startTime <= timeRange.before
? startTime
: timeRange.before - 60,
);
const mainCameraReviewItems = useMemo( const mainCameraReviewItems = useMemo(
() => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [], () => reviewItems?.filter((cam) => cam.camera == mainCamera) ?? [],
@ -107,8 +111,10 @@ export function RecordingView({
return chunk.after <= startTime && chunk.before >= startTime; return chunk.after <= startTime && chunk.before >= startTime;
}), }),
); );
const currentTimeRange = useMemo( const currentTimeRange = useMemo<TimeRange>(
() => chunkedTimeRange[selectedRangeIdx], () =>
chunkedTimeRange[selectedRangeIdx] ??
chunkedTimeRange[chunkedTimeRange.length - 1],
[selectedRangeIdx, chunkedTimeRange], [selectedRangeIdx, chunkedTimeRange],
); );
const reviewFilterList = useMemo(() => { const reviewFilterList = useMemo(() => {
@ -198,6 +204,10 @@ export function RecordingView({
const manuallySetCurrentTime = useCallback( const manuallySetCurrentTime = useCallback(
(time: number) => { (time: number) => {
if (!currentTimeRange) {
return;
}
setCurrentTime(time); setCurrentTime(time);
if (currentTimeRange.after <= time && currentTimeRange.before >= time) { if (currentTimeRange.after <= time && currentTimeRange.before >= time) {
@ -247,7 +257,8 @@ export function RecordingView({
// fullscreen // fullscreen
const { fullscreen, toggleFullscreen } = useFullscreen(mainLayoutRef); const { fullscreen, toggleFullscreen, supportsFullScreen } =
useFullscreen(mainLayoutRef);
// layout // layout
@ -507,7 +518,7 @@ export function RecordingView({
"pt-2 portrait:w-full", "pt-2 portrait:w-full",
mainCameraAspect == "wide" mainCameraAspect == "wide"
? "aspect-wide landscape:w-full" ? "aspect-wide landscape:w-full"
: "aspect-video landscape:h-[94%]", : "aspect-video landscape:h-[94%] landscape:xl:h-[65%]",
), ),
)} )}
style={{ style={{
@ -539,6 +550,7 @@ export function RecordingView({
mainControllerRef.current = controller; mainControllerRef.current = controller;
}} }}
isScrubbing={scrubbing || exportMode == "timeline"} isScrubbing={scrubbing || exportMode == "timeline"}
supportsFullscreen={supportsFullScreen}
setFullResolution={setFullResolution} setFullResolution={setFullResolution}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}
containerRef={mainLayoutRef} containerRef={mainLayoutRef}

View File

@ -41,6 +41,7 @@ import {
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
type DraggableGridLayoutProps = { type DraggableGridLayoutProps = {
cameras: CameraConfig[]; cameras: CameraConfig[];
@ -74,38 +75,11 @@ export default function DraggableGridLayout({
const birdseyeConfig = useMemo(() => config?.birdseye, [config]); const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
// preferred live modes per camera // preferred live modes per camera
const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode; const { preferredLiveModes, setPreferredLiveModes, resetPreferredLiveMode } =
}>({}); useCameraLiveMode(cameras, windowVisible);
const [liveViewMode] = usePersistence<LiveViewMode>("liveViewMode", "auto"); const [liveViewMode] = usePersistence<LiveViewMode>("liveViewMode", "auto");
useEffect(() => {
if (!cameras) return;
const mseSupported =
"MediaSource" in window || "ManagedMediaSource" in window;
const newPreferredLiveModes = cameras.reduce(
(acc, camera) => {
const isRestreamed =
config &&
Object.keys(config.go2rtc.streams || {}).includes(
camera.live.stream_name,
);
if (!mseSupported) {
acc[camera.name] = isRestreamed ? "webrtc" : "jsmpeg";
} else {
acc[camera.name] = isRestreamed ? "mse" : "jsmpeg";
}
return acc;
},
{} as { [key: string]: LivePlayerMode },
);
setPreferredLiveModes(newPreferredLiveModes);
}, [cameras, config, windowVisible]);
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []); const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence< const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence<
@ -478,6 +452,7 @@ export default function DraggableGridLayout({
return newModes; return newModes;
}); });
}} }}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
> >
{isEditMode && showCircles && <CornerCircles />} {isEditMode && showCircles && <CornerCircles />}
</LivePlayerGridItem> </LivePlayerGridItem>
@ -636,6 +611,7 @@ type LivePlayerGridItemProps = {
preferredLiveMode: LivePlayerMode; preferredLiveMode: LivePlayerMode;
onClick: () => void; onClick: () => void;
onError: (e: LivePlayerError) => void; onError: (e: LivePlayerError) => void;
onResetLiveMode: () => void;
liveViewMode?: LiveViewMode; liveViewMode?: LiveViewMode;
}; };
@ -657,6 +633,7 @@ const LivePlayerGridItem = React.forwardRef<
preferredLiveMode, preferredLiveMode,
onClick, onClick,
onError, onError,
onResetLiveMode,
liveViewMode, liveViewMode,
...props ...props
}, },
@ -679,6 +656,7 @@ const LivePlayerGridItem = React.forwardRef<
preferredLiveMode={preferredLiveMode} preferredLiveMode={preferredLiveMode}
onClick={onClick} onClick={onClick}
onError={onError} onError={onError}
onResetLiveMode={onResetLiveMode}
containerRef={ref as React.RefObject<HTMLDivElement>} containerRef={ref as React.RefObject<HTMLDivElement>}
autoLive={liveViewMode != "static"} autoLive={liveViewMode != "static"}
showStillWithoutActivity={liveViewMode != "continuous"} showStillWithoutActivity={liveViewMode != "continuous"}

View File

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

View File

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

View File

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

View File

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