Compare commits

...

53 Commits

Author SHA1 Message Date
Blake Blackshear
bccffe6670 TLS support (#11678)
* implement self signed cert and monitor/reload

* move go2rtc upstream to separate file

* add directory for ACME challenges

* make certsync more resilient

* add TLS docs

* add jwt secret info to docs
2024-06-01 10:29:46 -05:00
Nicolas Mowen
8418b65f34 Fix path containing too many / (#11680) 2024-06-01 08:24:20 -05:00
Soren L. Hansen
6e53c109b6 feat: apply ingress path to app paths (#11677)
When serving Frigate at a subpath, the paths that show in the URL bar
and that wind up in your browser history are anchored at the web root.
I.e. you go to `https://example.com/frigate/`, it changes to
`https://example.com/`, and clicking around works as expected, but the
`frigate/` prefix is gone.

It's confusing if you don't know that the URL's are entirely virtual.
Also, your browser history is useless, since the URL's point to e.g.
`https://example.com/#kitchen`, but visiting that URL will not hit
`/frigate/` at all.

Most of the work is already done. Nginx injects javascript to set
`window.baseURL` based on the X-Ingress-Path header. This change passes
that to BrowserRouter, so that it'll be part of the URL's it shows.

Fixes #4526
2024-06-01 07:08:01 -06:00
Blake Blackshear
7b99bbfd28 Update deps (#11679)
* update web deps

* update actions

* automatic stable tag publishing

* python deps

* typo
2024-06-01 06:39:05 -06:00
Nicolas Mowen
8179278bfa Don't fail if user has bind mounted nginx config (#11671) 2024-06-01 06:19:54 -05:00
Nicolas Mowen
758df09da3 Handle error when live view stalls (#11665)
* Handle error when live view stalls

* Manually calculate buffer timeout

* Formatting
2024-05-31 08:52:42 -05:00
Josh Hawkins
a3d116e70e stay in fullscreen when navigating to a camera (#11666) 2024-05-31 07:58:33 -05:00
Josh Hawkins
8c325801ef fix race where camera change effect sometimes was called after layout building (#11656) 2024-05-30 14:17:00 -06:00
Tom B
35946d332d Fix Statusbar rendering NaN% for unsupported GPUs (#11655) 2024-05-30 13:10:24 -06:00
Nicolas Mowen
142641b387 Adjust nginx proc count based on available CPUs (#11653)
* Restrict nginx to 4 processes if more are available

* Fix bash

* Different sed structure

* Limit ffmpeg thread counts for secondary ffmpeg processes

* Add up / down keyboard shortcut
2024-05-30 12:34:01 -05:00
Josh Hawkins
402c16e7df don't sleep mobile devices when fullscreen (#11652) 2024-05-30 09:37:08 -06:00
Nicolas Mowen
3e6b8c23bc Update dialog sizing for plus dialog (#11650) 2024-05-30 09:26:15 -05:00
Nicolas Mowen
1c5e7ebb48 UI Fixes (#11648)
* Add cursor pointer to preset dropdown

* Catch key index

* Fix iOS mime type
2024-05-30 07:41:37 -06:00
Josh Hawkins
9cb3e11df6 non-modal dropdown menus (#11649) 2024-05-30 07:39:14 -06:00
Nicolas Mowen
1c2e2a7b38 Handle case where user sets detections as empty list (#11646) 2024-05-30 07:45:34 -05:00
Josh Hawkins
a763ae303d static handlebar size to better match figma (#11638) 2024-05-29 21:34:19 -05:00
Nicolas Mowen
ec88752666 Don't show mark reviewed button when all items are in progress (#11636)
* Don't show mark reviewed button when all items are in progress

* Fix unknown preview file
2024-05-29 19:54:56 -05:00
Josh Hawkins
4135cabf58 assume 1 grid column by default on live dashboard (#11630) 2024-05-29 16:03:59 -06:00
Nicolas Mowen
9fc22efa2d Always save previews in UTC offset (#11629) 2024-05-29 14:55:56 -06:00
Josh Hawkins
37dd3fc25b fix birdseye fullscreen (#11625) 2024-05-29 14:18:51 -05:00
Nicolas Mowen
9e8202874e Remove live mode from config (#11618)
* Use preferred mode as default

* Remove live mode from config

* Add deer icon

* remove from config schema
2024-05-29 13:06:48 -05:00
Nicolas Mowen
9245c5cb56 Improve efficiency of log and metrics pages (#11622)
* Rework stats pages

* Handle limited data case

* Handle page and arrow keys

* Adjust sizing
2024-05-29 12:05:39 -06:00
Josh Hawkins
f1c0422d5e Various bugfixes and improvements (#11624)
* various bugfixes and improvements

* add separator

* no separator
2024-05-29 12:05:28 -06:00
Josh Hawkins
3dd401f57a string or list for camera groups (#11616) 2024-05-29 09:19:05 -05:00
Nicolas Mowen
6dd9660ecd Settings rework (#11613)
* refactor settings to be consistent with other page structure

* Implement non auto live

* Adjust missing view

* Quick fix

* Clarify settings options

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update naming and config restarts

* Rename

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2024-05-29 08:01:39 -06:00
Nicolas Mowen
d5f6decd30 Cache camera stream info to speed up future config generations (#11614)
* Cache camera stream info to speed up future config generations

* Formatting

* fix
2024-05-29 07:41:41 -06:00
Nicolas Mowen
cf4517cbdb cache current hour previews (#11606) 2024-05-28 17:35:55 -05:00
Nicolas Mowen
61f79afae9 Always use mse unless webrtc is requrested (#11605) 2024-05-28 17:35:36 -05:00
Nicolas Mowen
5513addab8 UI Fixes (#11602)
* Fix playback rate not showing

* Fix export image

* Formatting

* Formatting
2024-05-28 13:45:08 -06:00
Nicolas Mowen
d064e44571 Fix iOS fullscreen (#11600) 2024-05-28 13:10:42 -06:00
James Tanner-McLeod
c95758580f Draw motion mask first (#11598)
This draws the motion mask before the other overlay elements (such as bounding boxes), so that they are still visible. Fixes #11592
2024-05-28 12:44:20 -06:00
Josh Hawkins
ced5ab203f ensure the correct container is used for canvas calcs (#11599) 2024-05-28 12:41:51 -06:00
Nicolas Mowen
4236580672 UI fixes (#11596)
* Fix using undefined search params

* Fix calendar selection

* Simplify
2024-05-28 13:15:31 -05:00
Josh Hawkins
f7c3ddd380 check if motion dataframe is empty (#11593) 2024-05-28 11:33:28 -06:00
Nicolas Mowen
8546d3d315 Simplify timezone math (#11586)
* Use utc minutes

* Cleanup
2024-05-28 09:09:17 -05:00
Josh Hawkins
2fda383782 clean up unneeded code (#11587) 2024-05-28 08:05:04 -06:00
Josh Hawkins
4165639308 Live view tweaks and jsmpeg bugfix (#11584)
* live view tweaks and jsmpeg bugfix

* use container aspect in check
2024-05-28 08:11:35 -05:00
Nicolas Mowen
6913cc6abc Handle case where preview doesn't automatically changeover (#11583) 2024-05-28 08:11:23 -05:00
Nicolas Mowen
d64633889b Fixes (#11575)
* Fix settings icon

* Handle out of resources
2024-05-27 21:27:01 -05:00
Nicolas Mowen
7bed854ff7 remove libusb build (#11571) 2024-05-27 16:52:19 -06:00
Josh Hawkins
c1330704cf Make jsmpeg players fully responsive (#11567)
* make jsmpeg canvas responsive

* make birdseye responsive too
2024-05-27 16:18:04 -06:00
Nicolas Mowen
5900a2a4ba Add ability to interact with review items in events list (#11562)
* Add ability to interact with review items

* Ignore on iOS

* Don't load metadata

* Bug fixes
2024-05-27 17:12:57 -05:00
Blake Blackshear
bfeb7b8a96 upgrade to latest openvino version (#11563) 2024-05-27 14:49:35 -06:00
Nicolas Mowen
a86e22e0fc Fix live view updating when it shouldn't be (#11561)
* Simplify live image update logic

* Fix case where go2rtc is not setup
2024-05-27 09:50:02 -06:00
Josh Hawkins
c07f6999ca refresh editor value when config is updated (#11559) 2024-05-27 09:31:58 -06:00
Josh Hawkins
eca8c52f15 docs update: how to exclude camera from alerts/detections (#11560) 2024-05-27 09:31:49 -06:00
Nicolas Mowen
be147d218b Fix optimistic state (#11556) 2024-05-27 07:59:26 -05:00
Josh Hawkins
7a9ee63bd3 save video dimensions in onLoadedData instead of onLoadedMetadata (#11545) 2024-05-26 17:48:33 -06:00
Nicolas Mowen
c2eac10925 Tweaks and fixes (#11541)
* Update config version to be stored inside of the config

* Don't remove items from list when navigating back

* Use video api instead of webps for live current hour filmstrip

* Check that the config file is writable

* Show camera name when camera is offline

* Show camera name when offline

* Cleanup
2024-05-26 16:49:12 -05:00
Josh Hawkins
63d81bef45 redirect non existent paths to live view (#11536) 2024-05-26 10:17:52 -06:00
Josh Hawkins
3f171e7670 bugfixes (#11526) 2024-05-25 20:37:53 -06:00
Josh Hawkins
681c7367d7 ensure resizable handles are above jsmpeg canvas elements (#11519) 2024-05-25 15:15:30 -05:00
Marc Altmann
adb043e7ae Update docs for rockchip platform (#11503)
* improve docs for rockchip

* update version info

* fix typo

* fix typo

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

* fix typo

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

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2024-05-25 11:27:44 -06:00
115 changed files with 3062 additions and 2055 deletions

View File

@@ -220,7 +220,7 @@ jobs:
with: with:
string: ${{ github.repository }} string: ${{ github.repository }}
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}

View File

@@ -16,7 +16,7 @@ jobs:
with: with:
string: ${{ github.repository }} string: ${{ github.repository }}
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 uses: docker/login-action@0d4c9c5ea7693da7b068278f7b52bda2a190a446
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -24,14 +24,24 @@ jobs:
- 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") BRANCH=$([[ "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "master" || echo "dev")
echo "BRANCH=${BRANCH}" >> $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=${BRANCH}-${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: |
VERSION_TAG=${BASE}:${CLEAN_VERSION} VERSION_TAG=${BASE}:${CLEAN_VERSION}
STABLE_TAG=${BASE}:stable
PULL_TAG=${BASE}:${BUILD_TAG} PULL_TAG=${BASE}:${BUILD_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://${VERSION_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://${VERSION_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://${VERSION_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://${VERSION_TAG}-${variant}
done done
# stable tag
if [[ "${BRANCH}" == "master" ]]; then
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${STABLE_TAG}
for variant in standard-arm64 tensorrt tensorrt-jp4 tensorrt-jp5 rk; do
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${STABLE_TAG}-${variant}
done
fi

3
.gitignore vendored
View File

@@ -16,4 +16,5 @@ web/node_modules
web/coverage web/coverage
core core
!/web/**/*.ts !/web/**/*.ts
.idea/* .idea/*
.ipynb_checkpoints

View File

@@ -41,7 +41,6 @@ ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9
# OpenVino Support # OpenVino Support
# #
# 1. Download and convert a model from Intel's Public Open Model Zoo # 1. Download and convert a model from Intel's Public Open Model Zoo
# 2. Build libUSB without udev to handle NCS2 enumeration
# #
#### ####
# Download and Convert OpenVino model # Download and Convert OpenVino model
@@ -57,38 +56,11 @@ RUN apt-get -qq update \
&& pip install -r /requirements-ov.txt && pip install -r /requirements-ov.txt
# Get OpenVino Model # Get OpenVino Model
RUN mkdir /models \ RUN --mount=type=bind,source=docker/main/build_ov_model.py,target=/build_ov_model.py \
&& cd /models && omz_downloader --name ssdlite_mobilenet_v2 \ mkdir /models && cd /models \
&& cd /models && omz_converter --name ssdlite_mobilenet_v2 --precision FP16 && wget http://download.tensorflow.org/models/object_detection/ssdlite_mobilenet_v2_coco_2018_05_09.tar.gz \
&& tar -xvf ssdlite_mobilenet_v2_coco_2018_05_09.tar.gz \
&& python3 /build_ov_model.py
# libUSB - No Udev
FROM wget as libusb-build
ARG TARGETARCH
ARG DEBIAN_FRONTEND
ENV CCACHE_DIR /root/.ccache
ENV CCACHE_MAXSIZE 2G
# Build libUSB without udev. Needed for Openvino NCS2 support
WORKDIR /opt
RUN apt-get update && apt-get install -y unzip build-essential automake libtool ccache pkg-config
RUN --mount=type=cache,target=/root/.ccache wget -q https://github.com/libusb/libusb/archive/v1.0.26.zip -O v1.0.26.zip && \
unzip v1.0.26.zip && cd libusb-1.0.26 && \
./bootstrap.sh && \
./configure CC='ccache gcc' CCX='ccache g++' --disable-udev --enable-shared && \
make -j $(nproc --all)
RUN apt-get update && \
apt-get install -y --no-install-recommends libusb-1.0-0-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /opt/libusb-1.0.26/libusb
RUN /bin/mkdir -p '/usr/local/lib' && \
/bin/bash ../libtool --mode=install /usr/bin/install -c libusb-1.0.la '/usr/local/lib' && \
/bin/mkdir -p '/usr/local/include/libusb-1.0' && \
/usr/bin/install -c -m 644 libusb.h '/usr/local/include/libusb-1.0' && \
/bin/mkdir -p '/usr/local/lib/pkgconfig' && \
cd /opt/libusb-1.0.26/ && \
/usr/bin/install -c -m 644 libusb-1.0.pc '/usr/local/lib/pkgconfig' && \
ldconfig
FROM wget AS models FROM wget AS models
@@ -97,7 +69,8 @@ RUN wget -qO edgetpu_model.tflite https://github.com/google-coral/test_data/raw/
RUN wget -qO cpu_model.tflite https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess.tflite RUN wget -qO cpu_model.tflite https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess.tflite
COPY labelmap.txt . COPY labelmap.txt .
# Copy OpenVino model # Copy OpenVino model
COPY --from=ov-converter /models/public/ssdlite_mobilenet_v2/FP16 openvino-model COPY --from=ov-converter /models/ssdlite_mobilenet_v2.xml openvino-model/
COPY --from=ov-converter /models/ssdlite_mobilenet_v2.bin openvino-model/
RUN wget -q https://github.com/openvinotoolkit/open_model_zoo/raw/master/data/dataset_classes/coco_91cl_bkgr.txt -O openvino-model/coco_91cl_bkgr.txt && \ RUN wget -q https://github.com/openvinotoolkit/open_model_zoo/raw/master/data/dataset_classes/coco_91cl_bkgr.txt -O openvino-model/coco_91cl_bkgr.txt && \
sed -i 's/truck/car/g' openvino-model/coco_91cl_bkgr.txt sed -i 's/truck/car/g' openvino-model/coco_91cl_bkgr.txt
# Get Audio Model and labels # Get Audio Model and labels
@@ -158,7 +131,6 @@ RUN pip3 wheel --wheel-dir=/wheels -r /requirements-wheels.txt
FROM scratch AS deps-rootfs FROM scratch AS deps-rootfs
COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/ COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
COPY --from=go2rtc /rootfs/ / COPY --from=go2rtc /rootfs/ /
COPY --from=libusb-build /usr/local/lib /usr/local/lib
COPY --from=s6-overlay /rootfs/ / COPY --from=s6-overlay /rootfs/ /
COPY --from=models /rootfs/ / COPY --from=models /rootfs/ /
COPY docker/main/rootfs/ / COPY docker/main/rootfs/ /
@@ -188,8 +160,6 @@ RUN --mount=type=bind,from=wheels,source=/wheels,target=/deps/wheels \
COPY --from=deps-rootfs / / COPY --from=deps-rootfs / /
RUN ldconfig
EXPOSE 5000 EXPOSE 5000
EXPOSE 8554 EXPOSE 8554
EXPOSE 8555/tcp 8555/udp EXPOSE 8555/tcp 8555/udp

View File

@@ -0,0 +1,11 @@
import openvino as ov
from openvino.tools import mo
ov_model = mo.convert_model(
"/models/ssdlite_mobilenet_v2_coco_2018_05_09/frozen_inference_graph.pb",
compress_to_fp16=True,
transformations_config="/usr/local/lib/python3.9/dist-packages/openvino/tools/mo/front/tf/ssd_v2_support.json",
tensorflow_object_detection_api_pipeline_config="/models/ssdlite_mobilenet_v2_coco_2018_05_09/pipeline.config",
reverse_input_channels=True,
)
ov.save_model(ov_model, "/models/ssdlite_mobilenet_v2.xml")

View File

@@ -1,5 +1,3 @@
numpy numpy
# Openvino Library - Custom built with MYRIAD support tensorflow
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64' openvino-dev>=2024.0.0
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64'
openvino-dev[tensorflow2] @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino_dev-2022.3.1-1-py3-none-any.whl

View File

@@ -30,6 +30,4 @@ setproctitle == 1.3.*
ws4py == 0.5.* ws4py == 0.5.*
unidecode == 1.3.* unidecode == 1.3.*
onnxruntime == 1.16.* onnxruntime == 1.16.*
# Openvino Library - Custom built with MYRIAD support openvino == 2024.1.*
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-manylinux_2_31_x86_64.whl; platform_machine == 'x86_64'
openvino @ https://github.com/NateMeyer/openvino-wheels/releases/download/multi-arch_2022.3.1/openvino-2022.3.1-1-cp39-cp39-linux_aarch64.whl; platform_machine == 'aarch64'

View File

@@ -0,0 +1 @@
certsync

View File

@@ -0,0 +1 @@
certsync-pipeline

View File

@@ -0,0 +1,4 @@
#!/command/with-contenv bash
# shellcheck shell=bash
exec logutil-service /dev/shm/logs/certsync

View File

@@ -0,0 +1 @@
longrun

View File

@@ -0,0 +1,30 @@
#!/command/with-contenv bash
# shellcheck shell=bash
# Take down the S6 supervision tree when the service fails
set -o errexit -o nounset -o pipefail
# Logs should be sent to stdout so that s6 can collect them
declare exit_code_container
exit_code_container=$(cat /run/s6-linux-init-container-results/exitcode)
readonly exit_code_container
readonly exit_code_service="${1}"
readonly exit_code_signal="${2}"
readonly service="CERTSYNC"
echo "[INFO] Service ${service} exited with code ${exit_code_service} (by signal ${exit_code_signal})"
if [[ "${exit_code_service}" -eq 256 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo $((128 + exit_code_signal)) >/run/s6-linux-init-container-results/exitcode
fi
if [[ "${exit_code_signal}" -eq 15 ]]; then
exec /run/s6/basedir/bin/halt
fi
elif [[ "${exit_code_service}" -ne 0 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo "${exit_code_service}" >/run/s6-linux-init-container-results/exitcode
fi
exec /run/s6/basedir/bin/halt
fi

View File

@@ -0,0 +1 @@
certsync-log

View File

@@ -0,0 +1,53 @@
#!/command/with-contenv bash
# shellcheck shell=bash
# Start the CERTSYNC service
set -o errexit -o nounset -o pipefail
# Logs should be sent to stdout so that s6 can collect them
echo "[INFO] Starting certsync..."
lefile="/etc/letsencrypt/live/frigate/fullchain.pem"
while true
do
if [ ! -e $lefile ]
then
echo "[ERROR] TLS certificate does not exist: $lefile"
fi
leprint=`openssl x509 -in $lefile -fingerprint -noout || echo 'failed'`
case "$leprint" in
*Fingerprint*)
;;
*)
echo "[ERROR] Missing fingerprint from $lefile"
;;
esac
liveprint=`echo | openssl s_client -showcerts -connect 127.0.0.1:443 2>&1 | openssl x509 -fingerprint | grep -i fingerprint || echo 'failed'`
case "$liveprint" in
*Fingerprint*)
;;
*)
echo "[ERROR] Missing fingerprint from current nginx TLS cert"
;;
esac
if [[ "$leprint" != "failed" && "$liveprint" != "failed" && "$leprint" != "$liveprint" ]]
then
echo "[INFO] Reloading nginx to refresh TLS certificate"
echo "$lefile: $leprint"
/usr/local/nginx/sbin/nginx -s reload
fi
sleep 60
done
exit 0

View File

@@ -0,0 +1 @@
30000

View File

@@ -0,0 +1 @@
longrun

View File

@@ -4,7 +4,7 @@
set -o errexit -o nounset -o pipefail set -o errexit -o nounset -o pipefail
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx) dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/certsync)
mkdir -p "${dirs[@]}" mkdir -p "${dirs[@]}"
chown nobody:nogroup "${dirs[@]}" chown nobody:nogroup "${dirs[@]}"

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
# Wait for PID file to exist.
while ! test -f /run/nginx.pid; do sleep 1; done

View File

@@ -0,0 +1 @@
3

View File

@@ -8,6 +8,36 @@ set -o errexit -o nounset -o pipefail
echo "[INFO] Starting NGINX..." echo "[INFO] Starting NGINX..."
function set_worker_processes() {
# Capture number of assigned CPUs to calculate worker processes
local proc_count
if proc_count=$(nproc --all) && [[ $proc_count -gt 4 ]]; then
proc_count=4;
fi
# we need to catch any errors because sed will fail if user has bind mounted a custom nginx file
sed -i "s/worker_processes auto;/worker_processes ${proc_count};/" /usr/local/nginx/conf/nginx.conf || true
}
set_worker_processes
# ensure the directory for ACME challenges exists
mkdir -p /etc/letsencrypt/www
# Create self signed certs if needed
letsencrypt_path=/etc/letsencrypt/live/frigate
mkdir -p $letsencrypt_path
if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.pem" \) ]; then
echo "[INFO] No TLS certificate found. Generating a self signed certificate..."
openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem"
fi
# Replace the bash process with the NGINX process, redirecting stderr to stdout # Replace the bash process with the NGINX process, redirecting stderr to stdout
exec 2>&1 exec 2>&1
exec nginx exec \
s6-notifyoncheck -t 30000 -n 1 \
nginx

View File

@@ -0,0 +1,4 @@
upstream go2rtc {
server 127.0.0.1:1984;
keepalive 1024;
}

View File

@@ -56,9 +56,14 @@ http {
keepalive 1024; keepalive 1024;
} }
upstream go2rtc { include go2rtc_upstream.conf;
server 127.0.0.1:1984;
keepalive 1024; server {
listen [::]:80 ipv6only=off default_server;
location / {
return 301 https://$host$request_uri;
}
} }
server { server {
@@ -67,6 +72,8 @@ http {
# intended for internal traffic, not protected by auth # intended for internal traffic, not protected by auth
listen [::]:5000 ipv6only=off; listen [::]:5000 ipv6only=off;
include tls.conf;
# vod settings # vod settings
vod_base_url ''; vod_base_url '';
vod_segments_base_url ''; vod_segments_base_url '';

View File

@@ -0,0 +1,24 @@
keepalive_timeout 70;
listen [::]:443 ipv6only=off default_server ssl;
ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem;
# generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP
# https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # about 40000 sessions
ssl_session_tickets off;
# modern configuration
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers off;
# HSTS (ngx_http_headers_module is required) (63072000 seconds)
add_header Strict-Transport-Security "max-age=63072000" always;
# ACME challenge location
location /.well-known/acme-challenge/ {
default_type "text/plain";
root /etc/letsencrypt/www;
}

View File

@@ -52,6 +52,27 @@ auth:
- 172.18.0.0/16 # <---- this is the subnet for the internal docker compose network - 172.18.0.0/16 # <---- this is the subnet for the internal docker compose network
``` ```
#### JWT Token Secret
The JWT token secret needs to be kept secure. Anyone with this secret can generate valid JWT tokens to authenticate with Frigate. This should be a cryptographically random string of at least 64 characters.
You can generate a token using the Python secret library with the following command:
```shell
python3 -c 'import secrets; print(secrets.token_hex(64))'
```
Frigate looks for a JWT token secret in the following order:
1. An environment variable named `FRIGATE_JWT_SECRET`
2. A docker secret named `FRIGATE_JWT_SECRET` in `/run/secrets/`
3. A `jwt_secret` option from the Home Assistant Addon options
4. A `.jwt_secret` file in the config directory
If no secret is found on startup, Frigate generates one and stores it in a `.jwt_secret` file in the config directory.
Changing the secret will invalidate current tokens.
### Proxy mode ### Proxy mode
Proxy mode is designed to complement common upstream authentication proxies such as Authelia, Authentik, oauth2_proxy, or traefik-forward-auth. Proxy mode is designed to complement common upstream authentication proxies such as Authelia, Authentik, oauth2_proxy, or traefik-forward-auth.

View File

@@ -362,39 +362,11 @@ that NVDEC/NVDEC1 are in use.
## Rockchip platform ## Rockchip platform
Hardware accelerated video de-/encoding is supported on all Rockchip SoCs using [Nyanmisaka's FFmpeg Fork](https://github.com/nyanmisaka/ffmpeg-rockchip) based on [Rockchip's mpp library](https://github.com/rockchip-linux/mpp). Hardware accelerated video de-/encoding is supported on all Rockchip SoCs using [Nyanmisaka's FFmpeg 6.1 Fork](https://github.com/nyanmisaka/ffmpeg-rockchip) based on [Rockchip's mpp library](https://github.com/rockchip-linux/mpp).
### Prerequisites ### Prerequisites
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and rkvdec2 driver. To check, enter the following commands: Make sure to follow the [Rockchip specific installation instructions](/frigate/installation#rockchip-platform).
```
$ uname -r
5.10.xxx-rockchip # or 6.1.xxx; the -rockchip suffix is important
$ ls /dev/dri
by-path card0 card1 renderD128 renderD129 # should list renderD128
```
I recommend [Joshua Riek's Ubuntu for Rockchip](https://github.com/Joshua-Riek/ubuntu-rockchip), if your board is supported.
### Setup
Follow Frigate's default installation instructions, but use a docker image with `-rk` suffix for example `ghcr.io/blakeblackshear/frigate:stable-rk`.
Next, you need to grant docker permissions to access your hardware:
- During the configuration process, you should run docker in privileged mode to avoid any errors due to insufficient permissions. To do so, add `privileged: true` to your `docker-compose.yml` file or the `--privileged` flag to your docker run command.
- After everything works, you should only grant necessary permissions to increase security. Add the lines below to your `docker-compose.yml` file or the following options to your docker run command: `--security-opt systempaths=unconfined --security-opt apparmor=unconfined --device /dev/dri:/dev/dri --device /dev/dma_heap:/dev/dma_heap --device /dev/rga:/dev/rga --device /dev/mpp_service:/dev/mpp_service`:
```yaml
security_opt:
- apparmor=unconfined
- systempaths=unconfined
devices:
- /dev/dri:/dev/dri
- /dev/dma_heap:/dev/dma_heap
- /dev/rga:/dev/rga
- /dev/mpp_service:/dev/mpp_service
```
### Configuration ### Configuration

View File

@@ -313,39 +313,11 @@ Hardware accelerated object detection is supported on the following SoCs:
- RK3576 - RK3576
- RK3588 - RK3588
This implementation uses the [Rockchip's RKNN-Toolkit2](https://github.com/airockchip/rknn-toolkit2/) Currently, only [Yolo-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) is supported as object detection model. This implementation uses the [Rockchip's RKNN-Toolkit2](https://github.com/airockchip/rknn-toolkit2/), version v2.0.0.beta0. Currently, only [Yolo-NAS](https://github.com/Deci-AI/super-gradients/blob/master/YOLONAS.md) is supported as object detection model.
### Prerequisites ### Prerequisites
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and rknpu driver. To check, enter the following commands: Make sure to follow the [Rockchip specific installation instrucitions](/frigate/installation#rockchip-platform).
```
$ uname -r
5.10.xxx-rockchip # or 6.1.xxx; the -rockchip suffix is important
$ ls /dev/dri
by-path card0 card1 renderD128 renderD129 # should list renderD129
$ sudo cat /sys/kernel/debug/rknpu/version
RKNPU driver: v0.9.2 # or later version
```
I recommend [Joshua Riek's Ubuntu for Rockchip](https://github.com/Joshua-Riek/ubuntu-rockchip), if your board is supported.
### Setup
Follow Frigate's default installation instructions, but use a docker image with `-rk` suffix for example `ghcr.io/blakeblackshear/frigate:stable-rk`.
Next, you need to grant docker permissions to access your hardware:
- During the configuration process, you should run docker in privileged mode to avoid any errors due to insufficient permissions. To do so, add `privileged: true` to your `docker-compose.yml` file or the `--privileged` flag to your docker run command.
- After everything works, you should only grant necessary permissions to increase security. Add the lines below to your `docker-compose.yml` file or the following options to your docker run command: `--security-opt systempaths=unconfined --security-opt apparmor=unconfined --device /dev/dri:/dev/dri`:
```yaml
security_opt:
- apparmor=unconfined
- systempaths=unconfined
devices:
- /dev/dri:/dev/dri
```
### Configuration ### Configuration
@@ -405,6 +377,5 @@ $ cat /sys/kernel/debug/rknpu/load
::: :::
- By default the rknn detector uses the yolonas_s model (`model: path: default-fp16-yolonas_s`). This model comes with the image, so no further steps than those mentioned above are necessary and no download happens. - All models are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space.
- The other choices are automatically downloaded and stored in the folder `config/model_cache/rknn_cache`. After upgrading Frigate, you should remove older models to free up space. - You can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.
- Finally, you can also provide your own `.rknn` model. You should not save your own models in the `rknn_cache` folder, store them directly in the `model_cache` folder or another subfolder. To convert a model to `.rknn` format see the `rknn-toolkit2` (requires a x86 machine). Note, that there is only post-processing for the supported models.

View File

@@ -645,8 +645,6 @@ cameras:
# Optional # Optional
ui: ui:
# Optional: Set the default live mode for cameras in the UI (default: shown below)
live_mode: mse
# Optional: Set a timezone to use in the UI (default: use browser local time) # Optional: Set a timezone to use in the UI (default: use browser local time)
# timezone: America/Denver # timezone: America/Denver
# Optional: Set the time format used. # Optional: Set the time format used.

View File

@@ -42,6 +42,20 @@ review:
- dog - dog
``` ```
## Excluding a camera from alerts or detections
To exclude a specific camera from alerts or detections, simply provide an empty list to the alerts or detections field _at the camera level_.
For example, to exclude objects on the camera _gatecamera_ from any detections, include this in your config:
```yaml
cameras:
gatecamera:
review:
detections:
labels: []
```
## Restricting review items to specific zones ## Restricting review items to specific zones
By default a review item will be created if any `review -> alerts -> labels` and `review -> detections -> labels` are detected anywhere in the camera frame. You will likely want to configure review items to only be created when the object enters an area of interest, [see the zone docs for more information](./zones.md#restricting-alerts-and-detections-to-specific-zones) By default a review item will be created if any `review -> alerts -> labels` and `review -> detections -> labels` are detected anywhere in the camera frame. You will likely want to configure review items to only be created when the object enters an area of interest, [see the zone docs for more information](./zones.md#restricting-alerts-and-detections-to-specific-zones)

View File

@@ -0,0 +1,34 @@
---
id: tls
title: TLS
---
# TLS
Frigate's integrated NGINX server supports TLS certificates. By default Frigate will generate a self signed certificate that will be used for port 443. Frigate is designed to make it easy to use whatever tool you prefer to manage certificates.
Frigate is often running behind a reverse proxy that manages TLS certificates for multiple services. However, if you are running on a device that's separate from your proxy or if you expose Frigate directly to the internet, you may want to configure TLS.
## Certificates
TLS certificates can be mounted at `/etc/letsencrypt/live/frigate` using a bind mount or docker volume.
```yaml
frigate:
...
volumes:
- /path/to/your/certificate_folder:/etc/letsencrypt/live/frigate
...
```
Within the folder, the private key is expected to be named `privkey.pem` and the certificate is expected to be named `fullchain.pem`.
Frigate automatically compares the fingerprint of the certificate at `/etc/letsencrypt/live/frigate/fullchain.pem` against the fingerprint of the TLS cert in NGINX every minute. If these differ, the NGINX config is reloaded to pick up the updated certificate.
## ACME Challenge
Frigate also supports hosting the acme challenge files for the HTTP challenge method if needed. The challenge files should be mounted at `/etc/letsencrypt/www`.
## Advanced customization
If you would like to customize the TLS configuration, you can do so by using a bind mount to override `/usr/local/nginx/conf/tls.conf`. Check the source code for the default configuration and modify from there.

View File

@@ -95,6 +95,18 @@ Frigate supports all Jetson boards, from the inexpensive Jetson Nano to the powe
Inference speed will vary depending on the YOLO model, jetson platform and jetson nvpmodel (GPU/DLA/EMC clock speed). It is typically 20-40 ms for most models. The DLA is more efficient than the GPU, but not faster, so using the DLA will reduce power consumption but will slightly increase inference time. Inference speed will vary depending on the YOLO model, jetson platform and jetson nvpmodel (GPU/DLA/EMC clock speed). It is typically 20-40 ms for most models. The DLA is more efficient than the GPU, but not faster, so using the DLA will reduce power consumption but will slightly increase inference time.
#### Rockchip platform
Frigate supports hardware video processing on all Rockchip boards. However, hardware object detection is only supported on these boards:
- RK3562
- RK3566
- RK3568
- RK3576
- RK3588
The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms for yolo-nas s.
## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version) ## What does Frigate use the CPU for and what does it use a detector for? (ELI5 Version)
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity. This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.

View File

@@ -34,7 +34,8 @@ The following ports are used by Frigate and can be mapped via docker as required
| Port | Description | | Port | Description |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `8080` | Authenticated UI and API access. Reverse proxies should use this port. | | `8080` | Authenticated UI and API access without TLS. Reverse proxies should use this port. |
| `443` | Authenticated UI and API access with TLS. See the [TLS configuration](/configuration/tls) for more details. |
| `5000` | Internal unauthenticated UI and API access. Access to this port should be limited. Intended to be used within the docker network for services that integrate with Frigate. | | `5000` | Internal unauthenticated UI and API access. Access to this port should be limited. Intended to be used within the docker network for services that integrate with Frigate. |
| `8554` | RTSP restreaming. By default, these streams are unauthenticated. Authentication can be configured in go2rtc section of config. | | `8554` | RTSP restreaming. By default, these streams are unauthenticated. Authentication can be configured in go2rtc section of config. |
| `8555` | WebRTC connections for low latency live views. | | `8555` | WebRTC connections for low latency live views. |
@@ -44,7 +45,6 @@ The following ports are used by Frigate and can be mapped via docker as required
Writing to a local disk or external USB drive: Writing to a local disk or external USB drive:
```yaml ```yaml
version: "3.9"
services: services:
frigate: frigate:
... ...
@@ -95,6 +95,56 @@ By default, the Raspberry Pi limits the amount of memory available to the GPU. I
Additionally, the USB Coral draws a considerable amount of power. If using any other USB devices such as an SSD, you will experience instability due to the Pi not providing enough power to USB devices. You will need to purchase an external USB hub with it's own power supply. Some have reported success with <a href="https://amzn.to/3a2mH0P" target="_blank" rel="nofollow noopener sponsored">this</a> (affiliate link). Additionally, the USB Coral draws a considerable amount of power. If using any other USB devices such as an SSD, you will experience instability due to the Pi not providing enough power to USB devices. You will need to purchase an external USB hub with it's own power supply. Some have reported success with <a href="https://amzn.to/3a2mH0P" target="_blank" rel="nofollow noopener sponsored">this</a> (affiliate link).
### Rockchip platform
Make sure that you use a linux distribution that comes with the rockchip BSP kernel 5.10 or 6.1 and necessary drivers (especially rkvdec2 and rknpu). To check, enter the following commands:
```
$ uname -r
5.10.xxx-rockchip # or 6.1.xxx; the -rockchip suffix is important
$ ls /dev/dri
by-path card0 card1 renderD128 renderD129 # should list renderD128 (VPU) and renderD129 (NPU)
$ sudo cat /sys/kernel/debug/rknpu/version
RKNPU driver: v0.9.2 # or later version
```
I recommend [Joshua Riek's Ubuntu for Rockchip](https://github.com/Joshua-Riek/ubuntu-rockchip), if your board is supported.
#### Setup
Follow Frigate's default installation instructions, but use a docker image with `-rk` suffix for example `ghcr.io/blakeblackshear/frigate:stable-rk`.
Next, you need to grant docker permissions to access your hardware:
- During the configuration process, you should run docker in privileged mode to avoid any errors due to insufficient permissions. To do so, add `privileged: true` to your `docker-compose.yml` file or the `--privileged` flag to your docker run command.
- After everything works, you should only grant necessary permissions to increase security. Disable the privileged mode and add the lines below to your `docker-compose.yml` file:
```yaml
security_opt:
- apparmor=unconfined
- systempaths=unconfined
devices:
- /dev/dri
- /dev/dma_heap
- /dev/rga
- /dev/mpp_service
```
or add these options to your `docker run` command:
```
--security-opt systempaths=unconfined \
--security-opt apparmor=unconfined \
--device /dev/dri \
--device /dev/dma_heap \
--device /dev/rga \
--device /dev/mpp_service
```
#### Configuration
Next, you should configure [hardware object detection](/configuration/object_detectors#rockchip-platform) and [hardware video processing](/configuration/hardware_acceleration#rockchip-platform).
## Docker ## Docker
Running in Docker with compose is the recommended install method. Running in Docker with compose is the recommended install method.

665
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,9 +14,9 @@
"write-heading-ids": "docusaurus write-heading-ids" "write-heading-ids": "docusaurus write-heading-ids"
}, },
"dependencies": { "dependencies": {
"@docusaurus/core": "^3.3.2", "@docusaurus/core": "^3.4.0",
"@docusaurus/preset-classic": "^3.3.2", "@docusaurus/preset-classic": "^3.4.0",
"@docusaurus/theme-mermaid": "^3.3.2", "@docusaurus/theme-mermaid": "^3.4.0",
"@mdx-js/react": "^3.0.0", "@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"prism-react-renderer": "^2.1.0", "prism-react-renderer": "^2.1.0",
@@ -37,8 +37,8 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@docusaurus/module-type-aliases": "^3.3.2", "@docusaurus/module-type-aliases": "^3.4.0",
"@docusaurus/types": "^3.3.2", "@docusaurus/types": "^3.4.0",
"@types/react": "^18.2.79" "@types/react": "^18.2.79"
}, },
"engines": { "engines": {

View File

@@ -52,6 +52,7 @@ module.exports = {
"configuration/authentication", "configuration/authentication",
"configuration/hardware_acceleration", "configuration/hardware_acceleration",
"configuration/ffmpeg_presets", "configuration/ffmpeg_presets",
"configuration/tls",
"configuration/advanced", "configuration/advanced",
], ],
}, },

View File

@@ -1232,7 +1232,7 @@ def preview_gif(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
@MediaBp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/preview.mp4") @MediaBp.route("/<camera_name>/start/<int:start_ts>/end/<int:end_ts>/preview.mp4")
@MediaBp.route("/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/preview.mp4") @MediaBp.route("/<camera_name>/start/<float:start_ts>/end/<float:end_ts>/preview.mp4")
def preview_mp4(camera_name: str, start_ts, end_ts): def preview_mp4(camera_name: str, start_ts, end_ts, max_cache_age=2592000):
file_name = f"clip_{camera_name}_{start_ts}-{end_ts}.mp4" file_name = f"clip_{camera_name}_{start_ts}-{end_ts}.mp4"
if len(file_name) > 1000: if len(file_name) > 1000:
@@ -1380,7 +1380,7 @@ def preview_mp4(camera_name: str, start_ts, end_ts):
response = make_response() response = make_response()
response.headers["Content-Description"] = "File Transfer" response.headers["Content-Description"] = "File Transfer"
response.headers["Cache-Control"] = "no-cache" response.headers["Cache-Control"] = f"private, max-age={max_cache_age}"
response.headers["Content-Type"] = "video/mp4" response.headers["Content-Type"] = "video/mp4"
response.headers["Content-Length"] = os.path.getsize(path) response.headers["Content-Length"] = os.path.getsize(path)
response.headers["X-Accel-Redirect"] = ( response.headers["X-Accel-Redirect"] = (

View File

@@ -440,6 +440,11 @@ def motion_activity():
# resample data using pandas to get activity on scaled basis # resample data using pandas to get activity on scaled basis
df = pd.DataFrame(data, columns=["start_time", "motion", "camera"]) df = pd.DataFrame(data, columns=["start_time", "motion", "camera"])
if df.empty:
logger.warning("No motion data found for the requested time range")
return jsonify([])
df = df.astype(dtype={"motion": "float16"}) df = df.astype(dtype={"motion": "float16"})
# set date as datetime index # set date as datetime index

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import os import os
@@ -46,9 +45,9 @@ from frigate.util.builtin import (
get_ffmpeg_arg_list, get_ffmpeg_arg_list,
load_config_with_no_duplicates, load_config_with_no_duplicates,
) )
from frigate.util.config import get_relative_coordinates from frigate.util.config import StreamInfoRetriever, get_relative_coordinates
from frigate.util.image import create_mask from frigate.util.image import create_mask
from frigate.util.services import auto_detect_hwaccel, get_video_properties from frigate.util.services import auto_detect_hwaccel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,6 +72,9 @@ DEFAULT_DETECTORS = {"cpu": {"type": "cpu"}}
DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720} DEFAULT_DETECT_DIMENSIONS = {"width": 1280, "height": 720}
DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30" DEFAULT_TIME_LAPSE_FFMPEG_ARGS = "-vf setpts=0.04*PTS -r 30"
# stream info handler
stream_info_retriever = StreamInfoRetriever()
class FrigateBaseModel(BaseModel): class FrigateBaseModel(BaseModel):
model_config = ConfigDict(extra="forbid", protected_namespaces=()) model_config = ConfigDict(extra="forbid", protected_namespaces=())
@@ -98,9 +100,6 @@ class DateTimeStyleEnum(str, Enum):
class UIConfig(FrigateBaseModel): class UIConfig(FrigateBaseModel):
live_mode: LiveModeEnum = Field(
default=LiveModeEnum.mse, title="Default Live Mode."
)
timezone: Optional[str] = Field(default=None, title="Override UI timezone.") timezone: Optional[str] = Field(default=None, title="Override UI timezone.")
time_format: TimeFormatEnum = Field( time_format: TimeFormatEnum = Field(
default=TimeFormatEnum.browser, title="Override UI time format." default=TimeFormatEnum.browser, title="Override UI time format."
@@ -1169,12 +1168,20 @@ class LoggerConfig(FrigateBaseModel):
class CameraGroupConfig(FrigateBaseModel): class CameraGroupConfig(FrigateBaseModel):
"""Represents a group of cameras.""" """Represents a group of cameras."""
cameras: list[str] = Field( cameras: Union[str, List[str]] = Field(
default_factory=list, title="List of cameras in this group." default_factory=list, title="List of cameras in this group."
) )
icon: str = Field(default="generic", title="Icon that represents camera group.") icon: str = Field(default="generic", title="Icon that represents camera group.")
order: int = Field(default=0, title="Sort order for group.") order: int = Field(default=0, title="Sort order for group.")
@field_validator("cameras", mode="before")
@classmethod
def validate_cameras(cls, v):
if isinstance(v, str) and "," not in v:
return [v]
return v
def verify_config_roles(camera_config: CameraConfig) -> None: def verify_config_roles(camera_config: CameraConfig) -> None:
"""Verify that roles are setup in the config correctly.""" """Verify that roles are setup in the config correctly."""
@@ -1355,6 +1362,7 @@ class FrigateConfig(FrigateBaseModel):
default_factory=TimestampStyleConfig, default_factory=TimestampStyleConfig,
title="Global timestamp style configuration.", title="Global timestamp style configuration.",
) )
version: Optional[float] = Field(default=None, title="Current config version.")
def runtime_config(self, plus_api: PlusApi = None) -> FrigateConfig: def runtime_config(self, plus_api: PlusApi = None) -> FrigateConfig:
"""Merge camera config with globals.""" """Merge camera config with globals."""
@@ -1415,7 +1423,7 @@ class FrigateConfig(FrigateBaseModel):
if need_detect_dimensions or need_record_fourcc: if need_detect_dimensions or need_record_fourcc:
stream_info = {"width": 0, "height": 0, "fourcc": None} stream_info = {"width": 0, "height": 0, "fourcc": None}
try: try:
stream_info = asyncio.run(get_video_properties(input.path)) stream_info = stream_info_retriever.get_stream_info(input.path)
except Exception: except Exception:
logger.warn( logger.warn(
f"Error detecting stream parameters automatically for {input.path} Applying default values." f"Error detecting stream parameters automatically for {input.path} Applying default values."

View File

@@ -1,7 +1,7 @@
import logging import logging
import numpy as np import numpy as np
import openvino.runtime as ov import openvino as ov
from pydantic import Field from pydantic import Field
from typing_extensions import Literal from typing_extensions import Literal
@@ -23,28 +23,56 @@ class OvDetector(DetectionApi):
def __init__(self, detector_config: OvDetectorConfig): def __init__(self, detector_config: OvDetectorConfig):
self.ov_core = ov.Core() self.ov_core = ov.Core()
self.ov_model = self.ov_core.read_model(detector_config.model.path)
self.ov_model_type = detector_config.model.model_type self.ov_model_type = detector_config.model.model_type
self.h = detector_config.model.height self.h = detector_config.model.height
self.w = detector_config.model.width self.w = detector_config.model.width
self.interpreter = self.ov_core.compile_model( self.interpreter = self.ov_core.compile_model(
model=self.ov_model, device_name=detector_config.device model=detector_config.model.path, device_name=detector_config.device
) )
logger.info(f"Model Input Shape: {self.interpreter.input(0).shape}") self.model_invalid = False
self.output_indexes = 0
# Ensure the SSD model has the right input and output shapes
if self.ov_model_type == ModelTypeEnum.ssd:
model_inputs = self.interpreter.inputs
model_outputs = self.interpreter.outputs
if len(model_inputs) != 1:
logger.error(
f"SSD models must only have 1 input. Found {len(model_inputs)}."
)
self.model_invalid = True
if len(model_outputs) != 1:
logger.error(
f"SSD models must only have 1 output. Found {len(model_outputs)}."
)
self.model_invalid = True
if model_inputs[0].get_shape() != ov.Shape([1, self.w, self.h, 3]):
logger.error(
f"SSD model input doesn't match. Found {model_inputs[0].get_shape()}."
)
self.model_invalid = True
output_shape = model_outputs[0].get_shape()
if output_shape[0] != 1 or output_shape[1] != 1 or output_shape[3] != 7:
logger.error(f"SSD model output doesn't match. Found {output_shape}.")
self.model_invalid = True
while True:
try:
tensor_shape = self.interpreter.output(self.output_indexes).shape
logger.info(f"Model Output-{self.output_indexes} Shape: {tensor_shape}")
self.output_indexes += 1
except Exception:
logger.info(f"Model has {self.output_indexes} Output Tensors")
break
if self.ov_model_type == ModelTypeEnum.yolox: if self.ov_model_type == ModelTypeEnum.yolox:
self.output_indexes = 0
while True:
try:
tensor_shape = self.interpreter.output(self.output_indexes).shape
logger.info(
f"Model Output-{self.output_indexes} Shape: {tensor_shape}"
)
self.output_indexes += 1
except Exception:
logger.info(f"Model has {self.output_indexes} Output Tensors")
break
self.num_classes = tensor_shape[2] - 5 self.num_classes = tensor_shape[2] - 5
logger.info(f"YOLOX model has {self.num_classes} classes") logger.info(f"YOLOX model has {self.num_classes} classes")
self.set_strides_grids() self.set_strides_grids()
@@ -81,29 +109,32 @@ class OvDetector(DetectionApi):
def detect_raw(self, tensor_input): def detect_raw(self, tensor_input):
infer_request = self.interpreter.create_infer_request() infer_request = self.interpreter.create_infer_request()
infer_request.infer([tensor_input]) # TODO: see if we can use shared_memory=True
input_tensor = ov.Tensor(array=tensor_input)
infer_request.infer(input_tensor)
if self.ov_model_type == ModelTypeEnum.ssd: if self.ov_model_type == ModelTypeEnum.ssd:
results = infer_request.get_output_tensor()
detections = np.zeros((20, 6), np.float32) detections = np.zeros((20, 6), np.float32)
i = 0
for object_detected in results.data[0, 0, :]: if self.model_invalid:
if object_detected[0] != -1: return detections
logger.debug(object_detected)
if object_detected[2] < 0.1 or i == 20: results = infer_request.get_output_tensor(0).data[0][0]
for i, (_, class_id, score, xmin, ymin, xmax, ymax) in enumerate(results):
if i == 20:
break break
detections[i] = [ detections[i] = [
object_detected[1], # Label ID class_id,
float(object_detected[2]), # Confidence float(score),
object_detected[4], # y_min ymin,
object_detected[3], # x_min xmin,
object_detected[6], # y_max ymax,
object_detected[5], # x_max xmax,
] ]
i += 1
return detections return detections
elif self.ov_model_type == ModelTypeEnum.yolox:
if self.ov_model_type == ModelTypeEnum.yolox:
out_tensor = infer_request.get_output_tensor() out_tensor = infer_request.get_output_tensor()
# [x, y, h, w, box_score, class_no_1, ..., class_no_80], # [x, y, h, w, box_score, class_no_1, ..., class_no_80],
results = out_tensor.data results = out_tensor.data

View File

@@ -50,11 +50,13 @@ def get_ffmpeg_command(ffmpeg: FfmpegConfig) -> list[str]:
or get_ffmpeg_arg_list(ffmpeg.input_args) or get_ffmpeg_arg_list(ffmpeg.input_args)
) )
return ( return (
["ffmpeg", "-vn"] ["ffmpeg", "-vn", "-threads", "1"]
+ input_args + input_args
+ ["-i"] + ["-i"]
+ [ffmpeg_input.path] + [ffmpeg_input.path]
+ [ + [
"-threads",
"1",
"-f", "-f",
f"{AUDIO_FORMAT}", f"{AUDIO_FORMAT}",
"-ar", "-ar",

View File

@@ -498,6 +498,10 @@ class CameraState:
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420) frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
# draw on the frame # draw on the frame
if draw_options.get("mask"):
mask_overlay = np.where(self.camera_config.motion.mask == [0])
frame_copy[mask_overlay] = [0, 0, 0]
if draw_options.get("bounding_boxes"): if draw_options.get("bounding_boxes"):
# draw the bounding boxes on the frame # draw the bounding boxes on the frame
for obj in tracked_objects.values(): for obj in tracked_objects.values():
@@ -622,10 +626,6 @@ class CameraState:
) )
cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness) cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness)
if draw_options.get("mask"):
mask_overlay = np.where(self.camera_config.motion.mask == [0])
frame_copy[mask_overlay] = [0, 0, 0]
if draw_options.get("motion_boxes"): if draw_options.get("motion_boxes"):
for m_box in motion_boxes: for m_box in motion_boxes:
cv2.rectangle( cv2.rectangle(

View File

@@ -134,6 +134,8 @@ class FFMpegConverter(threading.Thread):
ffmpeg_cmd = [ ffmpeg_cmd = [
"ffmpeg", "ffmpeg",
"-threads",
"1",
"-f", "-f",
"rawvideo", "rawvideo",
"-pix_fmt", "-pix_fmt",
@@ -142,6 +144,8 @@ class FFMpegConverter(threading.Thread):
f"{in_width}x{in_height}", f"{in_width}x{in_height}",
"-i", "-i",
"pipe:", "pipe:",
"-threads",
"1",
"-f", "-f",
"mpegts", "mpegts",
"-s", "-s",

View File

@@ -31,6 +31,8 @@ class FFMpegConverter(threading.Thread):
ffmpeg_cmd = [ ffmpeg_cmd = [
"ffmpeg", "ffmpeg",
"-threads",
"1",
"-f", "-f",
"rawvideo", "rawvideo",
"-pix_fmt", "-pix_fmt",
@@ -39,6 +41,8 @@ class FFMpegConverter(threading.Thread):
f"{in_width}x{in_height}", f"{in_width}x{in_height}",
"-i", "-i",
"pipe:", "pipe:",
"-threads",
"1",
"-f", "-f",
"mpegts", "mpegts",
"-s", "-s",

View File

@@ -5,6 +5,7 @@ import logging
import os import os
import subprocess as sp import subprocess as sp
import threading import threading
import time
from pathlib import Path from pathlib import Path
import cv2 import cv2
@@ -101,12 +102,24 @@ class FFMpegConverter(threading.Thread):
f"duration {self.frame_times[t_idx + 1] - self.frame_times[t_idx]}" f"duration {self.frame_times[t_idx + 1] - self.frame_times[t_idx]}"
) )
p = sp.run( try:
self.ffmpeg_cmd.split(" "), p = sp.run(
input="\n".join(playlist), self.ffmpeg_cmd.split(" "),
encoding="ascii", input="\n".join(playlist),
capture_output=True, encoding="ascii",
) capture_output=True,
)
except BlockingIOError:
logger.warning(
f"Failed to create preview for {self.config.name}, retrying..."
)
time.sleep(2)
p = sp.run(
self.ffmpeg_cmd.split(" "),
input="\n".join(playlist),
encoding="ascii",
capture_output=True,
)
start = self.frame_times[0] start = self.frame_times[0]
end = self.frame_times[-1] end = self.frame_times[-1]
@@ -167,6 +180,7 @@ class PreviewRecorder:
# end segment at end of hour # end segment at end of hour
self.segment_end = ( self.segment_end = (
(datetime.datetime.now() + datetime.timedelta(hours=1)) (datetime.datetime.now() + datetime.timedelta(hours=1))
.astimezone(datetime.timezone.utc)
.replace(minute=0, second=0, microsecond=0) .replace(minute=0, second=0, microsecond=0)
.timestamp() .timestamp()
) )
@@ -179,6 +193,7 @@ class PreviewRecorder:
# check for existing items in cache # check for existing items in cache
start_ts = ( start_ts = (
datetime.datetime.now() datetime.datetime.now()
.astimezone(datetime.timezone.utc)
.replace(minute=0, second=0, microsecond=0) .replace(minute=0, second=0, microsecond=0)
.timestamp() .timestamp()
) )
@@ -194,7 +209,12 @@ class PreviewRecorder:
os.unlink(os.path.join(PREVIEW_CACHE_DIR, file)) os.unlink(os.path.join(PREVIEW_CACHE_DIR, file))
continue continue
ts = float(file.split("-")[1][: -(len(PREVIEW_FRAME_TYPE) + 1)]) file_time = file.split("-")[1][: -(len(PREVIEW_FRAME_TYPE) + 1)]
if not file_time:
continue
ts = float(file_time)
if self.start_time == 0: if self.start_time == 0:
self.start_time = ts self.start_time = ts
@@ -287,6 +307,7 @@ class PreviewRecorder:
# reset frame cache # reset frame cache
self.segment_end = ( self.segment_end = (
(datetime.datetime.now() + datetime.timedelta(hours=1)) (datetime.datetime.now() + datetime.timedelta(hours=1))
.astimezone(datetime.timezone.utc)
.replace(minute=0, second=0, microsecond=0) .replace(minute=0, second=0, microsecond=0)
.timestamp() .timestamp()
) )

View File

@@ -356,7 +356,7 @@ class ReviewSegmentMaintainer(threading.Thread):
if ( if (
not severity not severity
and ( and (
not camera_config.review.detections.labels camera_config.review.detections.labels is None
or object["label"] in (camera_config.review.detections.labels) or object["label"] in (camera_config.review.detections.labels)
) )
and ( and (
@@ -467,7 +467,7 @@ class ReviewSegmentMaintainer(threading.Thread):
current_segment.audio.add(audio) current_segment.audio.add(audio)
current_segment.severity = SeverityEnum.alert current_segment.severity = SeverityEnum.alert
elif ( elif (
not camera_config.review.detections.labels camera_config.review.detections.labels is None
or audio in camera_config.review.detections.labels or audio in camera_config.review.detections.labels
): ):
current_segment.audio.add(audio) current_segment.audio.add(audio)
@@ -510,7 +510,7 @@ class ReviewSegmentMaintainer(threading.Thread):
detections.add(audio) detections.add(audio)
severity = SeverityEnum.alert severity = SeverityEnum.alert
elif ( elif (
not camera_config.review.detections.labels camera_config.review.detections.labels is None
or audio in camera_config.review.detections.labels or audio in camera_config.review.detections.labels
): ):
detections.add(audio) detections.add(audio)
@@ -571,7 +571,7 @@ def get_active_objects(
and ( and (
o["label"] in camera_config.review.alerts.labels o["label"] in camera_config.review.alerts.labels
or ( or (
not camera_config.review.detections.labels camera_config.review.detections.labels is None
or o["label"] in camera_config.review.detections.labels or o["label"] in camera_config.review.detections.labels
) )
) # object must be in the alerts or detections label list ) # object must be in the alerts or detections label list

View File

@@ -237,7 +237,7 @@ def update_yaml(data, key_path, new_value):
temp[key[0]] += [{}] * (key[1] - len(temp[key[0]]) + 1) temp[key[0]] += [{}] * (key[1] - len(temp[key[0]]) + 1)
temp = temp[key[0]][key[1]] temp = temp[key[0]][key[1]]
else: else:
if key not in temp: if key not in temp or temp[key] is None:
temp[key] = {} temp[key] = {}
temp = temp[key] temp = temp[key]

View File

@@ -1,5 +1,6 @@
"""configuration utils.""" """configuration utils."""
import asyncio
import logging import logging
import os import os
import shutil import shutil
@@ -8,6 +9,7 @@ from typing import Optional, Union
from ruamel.yaml import YAML from ruamel.yaml import YAML
from frigate.const import CONFIG_DIR, EXPORT_DIR from frigate.const import CONFIG_DIR, EXPORT_DIR
from frigate.util.services import get_video_properties
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -17,16 +19,17 @@ CURRENT_CONFIG_VERSION = 0.14
def migrate_frigate_config(config_file: str): def migrate_frigate_config(config_file: str):
"""handle migrating the frigate config.""" """handle migrating the frigate config."""
logger.info("Checking if frigate config needs migration...") logger.info("Checking if frigate config needs migration...")
version_file = os.path.join(CONFIG_DIR, ".version")
if not os.path.isfile(version_file): if not os.access(config_file, mode=os.W_OK):
previous_version = 0.13 logger.error("Config file is read-only, unable to migrate config file.")
else: return
with open(version_file) as f:
try: yaml = YAML()
previous_version = float(f.readline()) yaml.indent(mapping=2, sequence=4, offset=2)
except Exception: with open(config_file, "r") as f:
previous_version = 0.13 config: dict[str, dict[str, any]] = yaml.load(f)
previous_version = config.get("version", 0.13)
if previous_version == CURRENT_CONFIG_VERSION: if previous_version == CURRENT_CONFIG_VERSION:
logger.info("frigate config does not need migration...") logger.info("frigate config does not need migration...")
@@ -35,11 +38,6 @@ def migrate_frigate_config(config_file: str):
logger.info("copying config as backup...") logger.info("copying config as backup...")
shutil.copy(config_file, os.path.join(CONFIG_DIR, "backup_config.yaml")) shutil.copy(config_file, os.path.join(CONFIG_DIR, "backup_config.yaml"))
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
with open(config_file, "r") as f:
config: dict[str, dict[str, any]] = yaml.load(f)
if previous_version < 0.14: if previous_version < 0.14:
logger.info(f"Migrating frigate config from {previous_version} to 0.14...") logger.info(f"Migrating frigate config from {previous_version} to 0.14...")
new_config = migrate_014(config) new_config = migrate_014(config)
@@ -57,9 +55,6 @@ def migrate_frigate_config(config_file: str):
os.path.join(EXPORT_DIR, file), os.path.join(EXPORT_DIR, new_name) os.path.join(EXPORT_DIR, file), os.path.join(EXPORT_DIR, new_name)
) )
with open(version_file, "w") as f:
f.write(str(CURRENT_CONFIG_VERSION))
logger.info("Finished frigate config migration...") logger.info("Finished frigate config migration...")
@@ -92,8 +87,12 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
if not new_config["record"]: if not new_config["record"]:
del new_config["record"] del new_config["record"]
if new_config.get("ui", {}).get("use_experimental"): if new_config.get("ui"):
del new_config["ui"]["experimental"] if new_config["ui"].get("use_experimental"):
del new_config["ui"]["experimental"]
if new_config["ui"].get("live_mode"):
del new_config["ui"]["live_mode"]
if not new_config["ui"]: if not new_config["ui"]:
del new_config["ui"] del new_config["ui"]
@@ -141,6 +140,7 @@ def migrate_014(config: dict[str, dict[str, any]]) -> dict[str, dict[str, any]]:
new_config["cameras"][name] = camera_config new_config["cameras"][name] = camera_config
new_config["version"] = 0.14
return new_config return new_config
@@ -200,3 +200,16 @@ def get_relative_coordinates(
return mask return mask
return mask return mask
class StreamInfoRetriever:
def __init__(self) -> None:
self.stream_cache: dict[str, tuple[int, int]] = {}
def get_stream_info(self, path: str) -> str:
if path in self.stream_cache:
return self.stream_cache[path]
info = asyncio.run(get_video_properties(path))
self.stream_cache[path] = info
return info

654
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,9 +46,10 @@
"immer": "^10.1.1", "immer": "^10.1.1",
"konva": "^9.3.9", "konva": "^9.3.9",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.379.0", "lucide-react": "^0.381.0",
"monaco-yaml": "^5.1.1", "monaco-yaml": "^5.1.1",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"nosleep.js": "^0.12.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-apexcharts": "^1.4.1", "react-apexcharts": "^1.4.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
@@ -92,7 +93,7 @@
"@vitejs/plugin-react-swc": "^3.6.0", "@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-v8": "^1.6.0", "@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^8.55.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",
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "^5.0.1",
@@ -105,7 +106,7 @@
"msw": "^2.3.0", "msw": "^2.3.0",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.14", "prettier-plugin-tailwindcss": "^0.6.1",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.11", "vite": "^5.2.11",

View File

@@ -20,12 +20,11 @@ const System = lazy(() => import("@/pages/System"));
const Settings = lazy(() => import("@/pages/Settings")); const Settings = lazy(() => import("@/pages/Settings"));
const UIPlayground = lazy(() => import("@/pages/UIPlayground")); const UIPlayground = lazy(() => import("@/pages/UIPlayground"));
const Logs = lazy(() => import("@/pages/Logs")); const Logs = lazy(() => import("@/pages/Logs"));
const NoMatch = lazy(() => import("@/pages/NoMatch"));
function App() { function App() {
return ( return (
<Providers> <Providers>
<BrowserRouter> <BrowserRouter basename={window.baseUrl}>
<Wrapper> <Wrapper>
<div className="size-full overflow-hidden"> <div className="size-full overflow-hidden">
{isDesktop && <Sidebar />} {isDesktop && <Sidebar />}
@@ -52,7 +51,7 @@ function App() {
<Route path="/config" element={<ConfigEditor />} /> <Route path="/config" element={<ConfigEditor />} />
<Route path="/logs" element={<Logs />} /> <Route path="/logs" element={<Logs />} />
<Route path="/playground" element={<UIPlayground />} /> <Route path="/playground" element={<UIPlayground />} />
<Route path="*" element={<NoMatch />} /> <Route path="*" element={<Redirect to="/" />} />
</Routes> </Routes>
</Suspense> </Suspense>
</div> </div>

View File

@@ -1,33 +1,20 @@
import { useFrigateStats } from "@/api/ws";
import { import {
StatusBarMessagesContext, StatusBarMessagesContext,
StatusMessage, StatusMessage,
} from "@/context/statusbar-provider"; } from "@/context/statusbar-provider";
import useStats from "@/hooks/use-stats"; import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import { FrigateStats } from "@/types/stats";
import { useContext, useEffect, useMemo } from "react"; import { useContext, useEffect, useMemo } from "react";
import { FaCheck } from "react-icons/fa"; import { FaCheck } from "react-icons/fa";
import { IoIosWarning } from "react-icons/io"; import { IoIosWarning } from "react-icons/io";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import useSWR from "swr";
export default function Statusbar() { export default function Statusbar() {
const { data: initialStats } = useSWR<FrigateStats>("stats", {
revalidateOnFocus: false,
});
const { payload: latestStats } = useFrigateStats();
const { messages, addMessage, clearMessages } = useContext( const { messages, addMessage, clearMessages } = useContext(
StatusBarMessagesContext, StatusBarMessagesContext,
)!; )!;
const stats = useMemo(() => { const stats = useAutoFrigateStats();
if (latestStats) {
return latestStats;
}
return initialStats;
}, [initialStats, latestStats]);
const cpuPercent = useMemo(() => { const cpuPercent = useMemo(() => {
const systemCpu = stats?.cpu_usages["frigate.full_system"]?.cpu; const systemCpu = stats?.cpu_usages["frigate.full_system"]?.cpu;
@@ -94,6 +81,10 @@ export default function Statusbar() {
const gpu = parseInt(stats.gpu); const gpu = parseInt(stats.gpu);
if (isNaN(gpu)) {
return;
}
return ( return (
<Link key={gpuTitle} to="/system#general"> <Link key={gpuTitle} to="/system#general">
{" "} {" "}

View File

@@ -69,7 +69,7 @@ export default function AutoUpdatingCameraImage({
<CameraImage <CameraImage
camera={camera} camera={camera}
onload={handleLoad} onload={handleLoad}
searchParams={`cache=${key}&${searchParams}`} searchParams={`cache=${key}${searchParams && `&${searchParams}`}`}
className={cameraClasses} className={cameraClasses}
/> />
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null} {showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}

View File

@@ -7,12 +7,10 @@ import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { RecordingStartingPoint } from "@/types/record"; import { RecordingStartingPoint } from "@/types/record";
import axios from "axios"; import axios from "axios";
import { import { VideoPreview } from "../player/PreviewThumbnailPlayer";
InProgressPreview,
VideoPreview,
} from "../player/PreviewThumbnailPlayer";
import { isCurrentHour } from "@/utils/dateUtil"; import { isCurrentHour } from "@/utils/dateUtil";
import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { baseUrl } from "@/api/baseUrl";
type AnimatedEventCardProps = { type AnimatedEventCardProps = {
event: ReviewSegment; event: ReviewSegment;
@@ -105,19 +103,19 @@ export function AnimatedEventCard({ event }: AnimatedEventCardProps) {
windowVisible={windowVisible} windowVisible={windowVisible}
/> />
) : ( ) : (
<InProgressPreview <video
review={event} preload="auto"
timeRange={{ autoPlay
after: event.start_time, playsInline
before: event.end_time ?? event.start_time + 20, muted
}} disableRemotePlayback
loop loop
showProgress={false} >
setReviewed={() => {}} <source
setIgnoreClick={() => {}} src={`${baseUrl}api/review/${event.id}/preview?format=mp4`}
isPlayingBack={() => {}} type="video/mp4"
windowVisible={windowVisible} />
/> </video>
)} )}
</div> </div>
<div className="absolute inset-x-0 bottom-0 h-6 rounded bg-gradient-to-t from-slate-900/50 to-transparent"> <div className="absolute inset-x-0 bottom-0 h-6 rounded bg-gradient-to-t from-slate-900/50 to-transparent">

View File

@@ -177,7 +177,7 @@ export default function ExportCard({
{exportedRecording.thumb_path.length > 0 ? ( {exportedRecording.thumb_path.length > 0 ? (
<img <img
className="absolute inset-0 aspect-video size-full rounded-lg object-contain md:rounded-2xl" className="absolute inset-0 aspect-video size-full rounded-lg object-contain md:rounded-2xl"
src={exportedRecording.thumb_path.replace("/media/frigate", "")} src={`${baseUrl}${exportedRecording.thumb_path.replace("/media/frigate/", "")}`}
onLoad={() => setLoading(false)} onLoad={() => setLoading(false)}
/> />
) : ( ) : (

View File

@@ -3,12 +3,24 @@ import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review"; import { ReviewSegment } from "@/types/review";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
import { 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 { useMemo } from "react"; import { useCallback, useMemo, 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 { FaCircleCheck } from "react-icons/fa6";
import { HiTrash } from "react-icons/hi";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../ui/context-menu";
import { Drawer, DrawerContent } from "../ui/drawer";
import axios from "axios";
import { toast } from "sonner";
type ReviewCardProps = { type ReviewCardProps = {
event: ReviewSegment; event: ReviewSegment;
@@ -33,10 +45,61 @@ export default function ReviewCard({
[event, currentTime], [event, currentTime],
); );
return ( const [optionsOpen, setOptionsOpen] = useState(false);
const onMarkAsReviewed = useCallback(async () => {
await axios.post(`reviews/viewed`, { ids: [event.id] });
event.has_been_reviewed = true;
setOptionsOpen(false);
}, [event]);
const onExport = useCallback(async () => {
axios
.post(
`export/${event.camera}/start/${event.start_time}/end/${event.end_time}`,
{ playback: "realtime" },
)
.then((response) => {
if (response.status == 200) {
toast.success(
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" },
);
}
})
.catch((error) => {
if (error.response?.data?.message) {
toast.error(
`Failed to start export: ${error.response.data.message}`,
{ position: "top-center" },
);
} else {
toast.error(`Failed to start export: ${error.message}`, {
position: "top-center",
});
}
});
setOptionsOpen(false);
}, [event]);
const onDelete = useCallback(async () => {
await axios.post(`reviews/delete`, { ids: [event.id] });
event.id = "";
setOptionsOpen(false);
}, [event]);
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"
onClick={onClick} onClick={onClick}
onContextMenu={
isDesktop
? undefined
: (e) => {
e.preventDefault();
setOptionsOpen(true);
}
}
> >
<ImageLoadingIndicator <ImageLoadingIndicator
className="absolute inset-0" className="absolute inset-0"
@@ -47,6 +110,15 @@ export default function ReviewCard({
className={`size-full rounded-lg ${isSelected ? "outline outline-[3px] outline-offset-1 outline-selected" : ""} ${imgLoaded ? "visible" : "invisible"}`} className={`size-full rounded-lg ${isSelected ? "outline outline-[3px] outline-offset-1 outline-selected" : ""} ${imgLoaded ? "visible" : "invisible"}`}
src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`} src={`${baseUrl}${event.thumb_path.replace("/media/frigate/", "")}`}
loading={isSafari ? "eager" : "lazy"} loading={isSafari ? "eager" : "lazy"}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
onLoad={() => { onLoad={() => {
onImgLoad(); onImgLoad();
}} }}
@@ -69,4 +141,78 @@ export default function ReviewCard({
</div> </div>
</div> </div>
); );
if (event.id == "") {
return;
}
if (isDesktop) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
</div>
</ContextMenuItem>
{!event.has_been_reviewed && (
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
</div>
</ContextMenuItem>
)}
<ContextMenuItem>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={onDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">Delete</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
return (
<Drawer open={optionsOpen} onOpenChange={setOptionsOpen}>
{content}
<DrawerContent>
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onExport}
>
<FaCompactDisc className="text-secondary-foreground" />
<div className="text-primary">Export</div>
</div>
{!event.has_been_reviewed && (
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onMarkAsReviewed}
>
<FaCircleCheck className="text-secondary-foreground" />
<div className="text-primary">Mark as reviewed</div>
</div>
)}
<div
className="flex w-full items-center justify-start gap-2 p-2"
onClick={onDelete}
>
<HiTrash className="text-secondary-foreground" />
<div className="text-primary">Delete</div>
</div>
</DrawerContent>
</Drawer>
);
} }

View File

@@ -468,7 +468,7 @@ export function CameraGroupRow({
{isMobile && ( {isMobile && (
<> <>
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" /> <HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -555,9 +555,7 @@ export function CameraGroupEdit({
message: "Invalid camera group name.", message: "Invalid camera group name.",
}), }),
cameras: z.array(z.string()).min(2, { cameras: z.array(z.string()),
message: "You must select at least two cameras.",
}),
icon: z icon: z
.string() .string()
.min(1, { message: "You must select an icon." }) .min(1, { message: "You must select an icon." })
@@ -663,6 +661,7 @@ export function CameraGroupEdit({
<FormDescription> <FormDescription>
Select cameras for this group. Select cameras for this group.
</FormDescription> </FormDescription>
<FormMessage />
{[ {[
...(birdseyeConfig?.enabled ? ["birdseye"] : []), ...(birdseyeConfig?.enabled ? ["birdseye"] : []),
...Object.keys(config?.cameras ?? {}), ...Object.keys(config?.cameras ?? {}),
@@ -680,7 +679,6 @@ export function CameraGroupEdit({
/> />
</FormControl> </FormControl>
))} ))}
<FormMessage />
</FormItem> </FormItem>
)} )}
/> />

View File

@@ -365,6 +365,7 @@ export function CamerasFilterButton({
return ( return (
<DropdownMenu <DropdownMenu
modal={false}
open={open} open={open}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {

View File

@@ -0,0 +1,138 @@
import { useTheme } from "@/context/theme-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useMemo } from "react";
import Chart from "react-apexcharts";
import { isMobileOnly } from "react-device-detect";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
const GRAPH_COLORS = ["#5C7CFA", "#ED5CFA", "#FAD75C"];
type CameraLineGraphProps = {
graphId: string;
unit: string;
dataLabels: string[];
updateTimes: number[];
data: ApexAxisChartSeries;
};
export function CameraLineGraph({
graphId,
unit,
dataLabels,
updateTimes,
data,
}: CameraLineGraphProps) {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const lastValues = useMemo<number[] | undefined>(() => {
if (!dataLabels || !data || data.length == 0) {
return undefined;
}
return dataLabels.map(
(_, labelIdx) =>
// @ts-expect-error y is valid
data[labelIdx].data[data[labelIdx].data.length - 1]?.y ?? 0,
) as number[];
}, [data, dataLabels]);
const { theme, systemTheme } = useTheme();
const formatTime = useCallback(
(val: unknown) => {
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
return date.toLocaleTimeString([], {
hour12: config?.ui.time_format != "24hour",
hour: "2-digit",
minute: "2-digit",
});
},
[config, updateTimes],
);
const options = useMemo(() => {
return {
chart: {
id: graphId,
selection: {
enabled: false,
},
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
},
colors: GRAPH_COLORS,
grid: {
show: false,
},
legend: {
show: false,
},
dataLabels: {
enabled: false,
},
stroke: {
width: 1,
},
tooltip: {
theme: systemTheme || theme,
},
markers: {
size: 0,
},
xaxis: {
tickAmount: isMobileOnly ? 3 : 4,
tickPlacement: "on",
labels: {
rotate: 0,
formatter: formatTime,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
yaxis: {
show: true,
labels: {
formatter: (val: number) => Math.ceil(val).toString(),
},
min: 0,
},
} as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme, formatTime]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]);
return (
<div className="flex w-full flex-col">
{lastValues && (
<div className="flex items-center gap-2.5">
{dataLabels.map((label, labelIdx) => (
<div key={label} className="flex items-center gap-1">
<MdCircle
className="size-2"
style={{ color: GRAPH_COLORS[labelIdx] }}
/>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-xs text-primary">
{lastValues[labelIdx]}
{unit}
</div>
</div>
))}
</div>
)}
<Chart type="line" options={options} series={data} height="120" />
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { useTheme } from "@/context/theme-provider";
import { useEffect, useMemo } from "react";
import Chart from "react-apexcharts";
const getUnitSize = (MB: number) => {
if (MB === null || isNaN(MB) || MB < 0) return "Invalid number";
if (MB < 1024) return `${MB.toFixed(2)} MiB`;
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
return `${(MB / 1048576).toFixed(2)} TiB`;
};
type StorageGraphProps = {
graphId: string;
used: number;
total: number;
};
export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
const { theme, systemTheme } = useTheme();
const options = useMemo(() => {
return {
chart: {
id: graphId,
background: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5",
selection: {
enabled: false,
},
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
},
grid: {
show: false,
padding: {
bottom: -40,
top: -60,
left: -20,
right: 0,
},
},
legend: {
show: false,
},
dataLabels: {
enabled: false,
},
plotOptions: {
bar: {
horizontal: true,
},
},
states: {
active: {
filter: {
type: "none",
},
},
hover: {
filter: {
type: "none",
},
},
},
tooltip: {
enabled: false,
},
xaxis: {
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
labels: {
show: false,
},
},
yaxis: {
show: false,
min: 0,
max: 100,
},
} as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]);
return (
<div className="flex w-full flex-col gap-2.5">
<div className="flex w-full items-center justify-between gap-1">
<div className="flex items-center gap-1">
<div className="text-xs text-primary">{getUnitSize(used)}</div>
<div className="text-xs text-primary">/</div>
<div className="text-xs text-muted-foreground">
{getUnitSize(total)}
</div>
</div>
<div className="text-xs text-primary">
{Math.round((used / total) * 100)}%
</div>
</div>
<div className="h-5 overflow-hidden rounded-md">
<Chart
type="bar"
options={options}
series={[
{ data: [{ x: "storage", y: Math.round((used / total) * 100) }] },
]}
height="100%"
/>
</div>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { Threshold } from "@/types/graph";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import Chart from "react-apexcharts"; import Chart from "react-apexcharts";
import { isMobileOnly } from "react-device-detect"; import { isMobileOnly } from "react-device-detect";
import { MdCircle } from "react-icons/md";
import useSWR from "swr"; import useSWR from "swr";
type ThresholdBarGraphProps = { type ThresholdBarGraphProps = {
@@ -37,7 +36,16 @@ export function ThresholdBarGraph({
const formatTime = useCallback( const formatTime = useCallback(
(val: unknown) => { (val: unknown) => {
const date = new Date(updateTimes[Math.round(val as number) - 1] * 1000); const dateIndex = Math.round(val as number);
let timeOffset = 0;
if (dateIndex < 0) {
timeOffset = 5000 * Math.abs(dateIndex);
}
const date = new Date(
updateTimes[Math.max(1, dateIndex) - 1] * 1000 - timeOffset,
);
return date.toLocaleTimeString([], { return date.toLocaleTimeString([], {
hour12: config?.ui.time_format != "24hour", hour12: config?.ui.time_format != "24hour",
hour: "2-digit", hour: "2-digit",
@@ -130,6 +138,22 @@ export function ThresholdBarGraph({
ApexCharts.exec(graphId, "updateOptions", options, true, true); ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]); }, [graphId, options]);
const chartData = useMemo(() => {
if (data.length > 0 && data[0].data.length >= 30) {
return data;
}
const copiedData = [...data];
const fakeData = [];
for (let i = data.length; i < 30; i++) {
fakeData.push({ x: i - 30, y: 0 });
}
// @ts-expect-error data types are not obvious
copiedData[0].data = [...fakeData, ...data[0].data];
return copiedData;
}, [data]);
return ( return (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -139,255 +163,7 @@ export function ThresholdBarGraph({
{unit} {unit}
</div> </div>
</div> </div>
<Chart type="bar" options={options} series={data} height="120" /> <Chart type="bar" options={options} series={chartData} height="120" />
</div>
);
}
const getUnitSize = (MB: number) => {
if (MB === null || isNaN(MB) || MB < 0) return "Invalid number";
if (MB < 1024) return `${MB.toFixed(2)} MiB`;
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
return `${(MB / 1048576).toFixed(2)} TiB`;
};
type StorageGraphProps = {
graphId: string;
used: number;
total: number;
};
export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
const { theme, systemTheme } = useTheme();
const options = useMemo(() => {
return {
chart: {
id: graphId,
background: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5",
selection: {
enabled: false,
},
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
},
grid: {
show: false,
padding: {
bottom: -40,
top: -60,
left: -20,
right: 0,
},
},
legend: {
show: false,
},
dataLabels: {
enabled: false,
},
plotOptions: {
bar: {
horizontal: true,
},
},
states: {
active: {
filter: {
type: "none",
},
},
hover: {
filter: {
type: "none",
},
},
},
tooltip: {
enabled: false,
},
xaxis: {
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
labels: {
show: false,
},
},
yaxis: {
show: false,
min: 0,
max: 100,
},
} as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]);
return (
<div className="flex w-full flex-col gap-2.5">
<div className="flex w-full items-center justify-between gap-1">
<div className="flex items-center gap-1">
<div className="text-xs text-primary">{getUnitSize(used)}</div>
<div className="text-xs text-primary">/</div>
<div className="text-xs text-muted-foreground">
{getUnitSize(total)}
</div>
</div>
<div className="text-xs text-primary">
{Math.round((used / total) * 100)}%
</div>
</div>
<div className="h-5 overflow-hidden rounded-md">
<Chart
type="bar"
options={options}
series={[
{ data: [{ x: "storage", y: Math.round((used / total) * 100) }] },
]}
height="100%"
/>
</div>
</div>
);
}
const GRAPH_COLORS = ["#5C7CFA", "#ED5CFA", "#FAD75C"];
type CameraLineGraphProps = {
graphId: string;
unit: string;
dataLabels: string[];
updateTimes: number[];
data: ApexAxisChartSeries;
};
export function CameraLineGraph({
graphId,
unit,
dataLabels,
updateTimes,
data,
}: CameraLineGraphProps) {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const lastValues = useMemo<number[] | undefined>(() => {
if (!dataLabels || !data || data.length == 0) {
return undefined;
}
return dataLabels.map(
(_, labelIdx) =>
// @ts-expect-error y is valid
data[labelIdx].data[data[labelIdx].data.length - 1]?.y ?? 0,
) as number[];
}, [data, dataLabels]);
const { theme, systemTheme } = useTheme();
const formatTime = useCallback(
(val: unknown) => {
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
return date.toLocaleTimeString([], {
hour12: config?.ui.time_format != "24hour",
hour: "2-digit",
minute: "2-digit",
});
},
[config, updateTimes],
);
const options = useMemo(() => {
return {
chart: {
id: graphId,
selection: {
enabled: false,
},
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
},
colors: GRAPH_COLORS,
grid: {
show: false,
},
legend: {
show: false,
},
dataLabels: {
enabled: false,
},
stroke: {
width: 1,
},
tooltip: {
theme: systemTheme || theme,
},
markers: {
size: 0,
},
xaxis: {
tickAmount: isMobileOnly ? 3 : 4,
tickPlacement: "on",
labels: {
rotate: 0,
formatter: formatTime,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
yaxis: {
show: true,
labels: {
formatter: (val: number) => Math.ceil(val).toString(),
},
min: 0,
},
} as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme, formatTime]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]);
return (
<div className="flex w-full flex-col">
{lastValues && (
<div className="flex items-center gap-2.5">
{dataLabels.map((label, labelIdx) => (
<div key={label} className="flex items-center gap-1">
<MdCircle
className="size-2"
style={{ color: GRAPH_COLORS[labelIdx] }}
/>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-xs text-primary">
{lastValues[labelIdx]}
{unit}
</div>
</div>
))}
</div>
)}
<Chart type="line" options={options} series={data} height="120" />
</div> </div>
); );
} }

View File

@@ -34,7 +34,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
const MenuItem = isDesktop ? DropdownMenuItem : DialogClose; const MenuItem = isDesktop ? DropdownMenuItem : DialogClose;
return ( return (
<Container> <Container modal={!isDesktop}>
<Trigger> <Trigger>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -66,6 +66,7 @@ import {
DialogTrigger, DialogTrigger,
} from "../ui/dialog"; } from "../ui/dialog";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
type GeneralSettingsProps = { type GeneralSettingsProps = {
className?: string; className?: string;
@@ -113,249 +114,249 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
return ( return (
<> <>
<div className={className}> <Container modal={!isDesktop}>
<Container> <Trigger>
<Trigger> <Tooltip>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <div
<div className={cn(
className={`flex flex-col items-center justify-center ${isDesktop ? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted" : "text-secondary-foreground"}`} "flex flex-col items-center justify-center",
> isDesktop
<LuSettings className="size-5 md:m-[6px]" /> ? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted"
</div> : "text-secondary-foreground",
</TooltipTrigger> className,
<TooltipPortal> )}
<TooltipContent side="right"> >
<p>Settings</p> <LuSettings className="size-5 md:m-[6px]" />
</TooltipContent> </div>
</TooltipPortal> </TooltipTrigger>
</Tooltip> <TooltipPortal>
</Trigger> <TooltipContent side="right">
<Content <p>Settings</p>
className={ </TooltipContent>
isDesktop ? "mr-5 w-72" : "max-h-[75dvh] overflow-hidden p-2" </TooltipPortal>
} </Tooltip>
> </Trigger>
<div className="w-full flex-col overflow-y-auto overflow-x-hidden"> <Content
<DropdownMenuLabel>System</DropdownMenuLabel> className={
<DropdownMenuSeparator /> isDesktop ? "mr-5 w-72" : "max-h-[75dvh] overflow-hidden p-2"
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}> }
<Link to="/system#general"> >
<MenuItem <div className="w-full flex-col overflow-y-auto overflow-x-hidden">
className={ <DropdownMenuLabel>System</DropdownMenuLabel>
isDesktop <DropdownMenuSeparator />
? "cursor-pointer" <DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
: "flex w-full items-center p-2 text-sm" <Link to="/system#general">
}
>
<LuActivity className="mr-2 size-4" />
<span>System metrics</span>
</MenuItem>
</Link>
<Link to="/logs">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuList className="mr-2 size-4" />
<span>System logs</span>
</MenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Configuration
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link to="/settings">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuSettings className="mr-2 size-4" />
<span>Settings</span>
</MenuItem>
</Link>
<Link to="/config">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuPenSquare className="mr-2 size-4" />
<span>Configuration editor</span>
</MenuItem>
</Link>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Appearance
</DropdownMenuLabel>
<DropdownMenuSeparator />
<SubItem>
<SubItemTrigger
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span>Dark Mode</span>
</SubItemTrigger>
<Portal>
<SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("light")}
>
{theme === "light" ? (
<>
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
Light
</>
) : (
<span className="ml-6 mr-2">Light</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("dark")}
>
{theme === "dark" ? (
<>
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
Dark
</>
) : (
<span className="ml-6 mr-2">Dark</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("system")}
>
{theme === "system" ? (
<>
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
System
</>
) : (
<span className="ml-6 mr-2">System</span>
)}
</MenuItem>
</SubItemContent>
</Portal>
</SubItem>
<SubItem>
<SubItemTrigger
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
>
<LuSunMoon className="mr-2 size-4" />
<span>Theme</span>
</SubItemTrigger>
<Portal>
<SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
{colorSchemes.map((scheme) => (
<MenuItem
key={scheme}
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setColorScheme(scheme)}
>
{scheme === colorScheme ? (
<>
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
{friendlyColorSchemeName(scheme)}
</>
) : (
<span className="ml-6 mr-2">
{friendlyColorSchemeName(scheme)}
</span>
)}
</MenuItem>
))}
</SubItemContent>
</Portal>
</SubItem>
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Help
</DropdownMenuLabel>
<DropdownMenuSeparator />
<a href="https://docs.frigate.video">
<MenuItem <MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuActivity className="mr-2 size-4" />
<span>System metrics</span>
</MenuItem>
</Link>
<Link to="/logs">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuList className="mr-2 size-4" />
<span>System logs</span>
</MenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Configuration
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link to="/settings">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuSettings className="mr-2 size-4" />
<span>Settings</span>
</MenuItem>
</Link>
<Link to="/config">
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex w-full items-center p-2 text-sm"
}
>
<LuPenSquare className="mr-2 size-4" />
<span>Configuration editor</span>
</MenuItem>
</Link>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Appearance
</DropdownMenuLabel>
<DropdownMenuSeparator />
<SubItem>
<SubItemTrigger
className={ className={
isDesktop isDesktop
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
> >
<LuLifeBuoy className="mr-2 size-4" /> <LuSunMoon className="mr-2 size-4" />
<span>Documentation</span> <span>Dark Mode</span>
</MenuItem> </SubItemTrigger>
</a> <Portal>
<a href="https://github.com/blakeblackshear/frigate"> <SubItemContent
<MenuItem className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("light")}
>
{theme === "light" ? (
<>
<LuSun className="mr-2 size-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
Light
</>
) : (
<span className="ml-6 mr-2">Light</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("dark")}
>
{theme === "dark" ? (
<>
<LuMoon className="mr-2 size-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
Dark
</>
) : (
<span className="ml-6 mr-2">Dark</span>
)}
</MenuItem>
<MenuItem
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setTheme("system")}
>
{theme === "system" ? (
<>
<CgDarkMode className="mr-2 size-4 scale-100 transition-all" />
System
</>
) : (
<span className="ml-6 mr-2">System</span>
)}
</MenuItem>
</SubItemContent>
</Portal>
</SubItem>
<SubItem>
<SubItemTrigger
className={ className={
isDesktop isDesktop
? "cursor-pointer" ? "cursor-pointer"
: "flex items-center p-2 text-sm" : "flex items-center p-2 text-sm"
} }
> >
<LuGithub className="mr-2 size-4" /> <LuSunMoon className="mr-2 size-4" />
<span>GitHub</span> <span>Theme</span>
</MenuItem> </SubItemTrigger>
</a> <Portal>
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} /> <SubItemContent
className={
isDesktop ? "" : "w-[92%] rounded-lg md:rounded-2xl"
}
>
<span tabIndex={0} className="sr-only" />
{colorSchemes.map((scheme) => (
<MenuItem
key={scheme}
className={
isDesktop
? "cursor-pointer"
: "flex items-center p-2 text-sm"
}
onClick={() => setColorScheme(scheme)}
>
{scheme === colorScheme ? (
<>
<IoColorPalette className="mr-2 size-4 rotate-0 scale-100 transition-all" />
{friendlyColorSchemeName(scheme)}
</>
) : (
<span className="ml-6 mr-2">
{friendlyColorSchemeName(scheme)}
</span>
)}
</MenuItem>
))}
</SubItemContent>
</Portal>
</SubItem>
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
Help
</DropdownMenuLabel>
<DropdownMenuSeparator />
<a href="https://docs.frigate.video">
<MenuItem <MenuItem
className={ className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm" isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
} }
onClick={() => setRestartDialogOpen(true)}
> >
<LuRotateCw className="mr-2 size-4" /> <LuLifeBuoy className="mr-2 size-4" />
<span>Restart Frigate</span> <span>Documentation</span>
</MenuItem> </MenuItem>
</div> </a>
</Content> <a href="https://github.com/blakeblackshear/frigate">
</Container> <MenuItem
</div> className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
>
<LuGithub className="mr-2 size-4" />
<span>GitHub</span>
</MenuItem>
</a>
<DropdownMenuSeparator className={isDesktop ? "mt-3" : "mt-1"} />
<MenuItem
className={
isDesktop ? "cursor-pointer" : "flex items-center p-2 text-sm"
}
onClick={() => setRestartDialogOpen(true)}
>
<LuRotateCw className="mr-2 size-4" />
<span>Restart Frigate</span>
</MenuItem>
</div>
</Content>
</Container>
{restartDialogOpen && ( {restartDialogOpen && (
<AlertDialog <AlertDialog
open={restartDialogOpen} open={restartDialogOpen}

View File

@@ -3,6 +3,7 @@ import { Calendar } from "../ui/calendar";
import { useMemo } from "react"; import { useMemo } from "react";
import { FaCircle } from "react-icons/fa"; import { FaCircle } from "react-icons/fa";
import { getUTCOffset } from "@/utils/dateUtil"; import { getUTCOffset } from "@/utils/dateUtil";
import { type DayContentProps } from "react-day-picker";
type ReviewActivityCalendarProps = { type ReviewActivityCalendarProps = {
reviewSummary?: ReviewSummary; reviewSummary?: ReviewSummary;
@@ -22,6 +23,28 @@ export default function ReviewActivityCalendar({
return { from: tomorrow, to: future }; return { from: tomorrow, to: future };
}, []); }, []);
const modifiers = useMemo(() => {
if (!reviewSummary) {
return { alerts: [], detections: [] };
}
const unreviewedDetections: Date[] = [];
const unreviewedAlerts: Date[] = [];
Object.entries(reviewSummary).forEach(([date, data]) => {
if (data.total_alert > data.reviewed_alert) {
unreviewedAlerts.push(new Date(date));
} else if (data.total_detection > data.reviewed_detection) {
unreviewedDetections.push(new Date(date));
}
});
return {
alerts: unreviewedAlerts,
detections: unreviewedDetections,
};
}, [reviewSummary]);
return ( return (
<Calendar <Calendar
mode="single" mode="single"
@@ -29,46 +52,28 @@ export default function ReviewActivityCalendar({
showOutsideDays={false} showOutsideDays={false}
selected={selectedDay} selected={selectedDay}
onSelect={onSelect} onSelect={onSelect}
modifiers={modifiers}
components={{ components={{
DayContent: (date) => ( DayContent: ReviewActivityDay,
<ReviewActivityDay reviewSummary={reviewSummary} day={date.date} />
),
}} }}
/> />
); );
} }
type ReviewActivityDayProps = { function ReviewActivityDay({ date, activeModifiers }: DayContentProps) {
reviewSummary?: ReviewSummary;
day: Date;
};
function ReviewActivityDay({ reviewSummary, day }: ReviewActivityDayProps) {
const dayActivity = useMemo(() => { const dayActivity = useMemo(() => {
if (!reviewSummary) { if (activeModifiers["alerts"]) {
return "none";
}
const allActivity =
reviewSummary[
`${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`
];
if (!allActivity) {
return "none";
}
if (allActivity.total_alert > allActivity.reviewed_alert) {
return "alert"; return "alert";
} else if (allActivity.total_detection > allActivity.reviewed_detection) { } else if (activeModifiers["detections"]) {
return "detection"; return "detection";
} else { } else {
return "none"; return "none";
} }
}, [reviewSummary, day]); }, [activeModifiers]);
return ( return (
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center gap-0.5">
{day.getDate()} {date.getDate()}
{dayActivity != "none" && ( {dayActivity != "none" && (
<FaCircle <FaCircle
className={`size-2 ${dayActivity == "alert" ? "fill-severity_alert" : "fill-severity_detection"}`} className={`size-2 ${dayActivity == "alert" ? "fill-severity_alert" : "fill-severity_detection"}`}

View File

@@ -5,12 +5,14 @@ import JSMpegPlayer from "./JSMpegPlayer";
import MSEPlayer from "./MsePlayer"; import MSEPlayer from "./MsePlayer";
import { LivePlayerMode } from "@/types/live"; import { LivePlayerMode } from "@/types/live";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import React from "react";
type LivePlayerProps = { type LivePlayerProps = {
className?: string; className?: string;
birdseyeConfig: BirdseyeConfig; birdseyeConfig: BirdseyeConfig;
liveMode: LivePlayerMode; liveMode: LivePlayerMode;
onClick?: () => void; onClick?: () => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
}; };
export default function BirdseyeLivePlayer({ export default function BirdseyeLivePlayer({
@@ -18,6 +20,7 @@ export default function BirdseyeLivePlayer({
birdseyeConfig, birdseyeConfig,
liveMode, liveMode,
onClick, onClick,
containerRef,
}: LivePlayerProps) { }: LivePlayerProps) {
let player; let player;
if (liveMode == "webrtc") { if (liveMode == "webrtc") {
@@ -50,6 +53,7 @@ export default function BirdseyeLivePlayer({
camera="birdseye" camera="birdseye"
width={birdseyeConfig.width} width={birdseyeConfig.width}
height={birdseyeConfig.height} height={birdseyeConfig.height}
containerRef={containerRef}
/> />
); );
} else { } else {

View File

@@ -39,7 +39,7 @@ type HlsVideoPlayerProps = {
onPlaying?: () => void; onPlaying?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined; onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
setFullscreen?: (full: boolean) => void; toggleFullscreen?: () => void;
}; };
export default function HlsVideoPlayer({ export default function HlsVideoPlayer({
videoRef, videoRef,
@@ -53,7 +53,7 @@ export default function HlsVideoPlayer({
onPlaying, onPlaying,
setFullResolution, setFullResolution,
onUploadFrame, onUploadFrame,
setFullscreen, toggleFullscreen,
}: HlsVideoPlayerProps) { }: HlsVideoPlayerProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@@ -224,7 +224,7 @@ export default function HlsVideoPlayer({
} }
}} }}
fullscreen={fullscreen} fullscreen={fullscreen}
setFullscreen={setFullscreen} toggleFullscreen={toggleFullscreen}
/> />
<TransformComponent <TransformComponent
wrapperStyle={{ wrapperStyle={{
@@ -272,8 +272,8 @@ export default function HlsVideoPlayer({
? onTimeUpdate(videoRef.current.currentTime) ? onTimeUpdate(videoRef.current.currentTime)
: undefined : undefined
} }
onLoadedData={onPlayerLoaded} onLoadedData={() => {
onLoadedMetadata={() => { onPlayerLoaded?.();
handleLoadedMetadata(); handleLoadedMetadata();
if (videoRef.current) { if (videoRef.current) {

View File

@@ -1,13 +1,15 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { useResizeObserver } from "@/hooks/resize-observer";
// @ts-expect-error we know this doesn't have types // @ts-expect-error we know this doesn't have types
import JSMpeg from "@cycjimmy/jsmpeg-player"; import JSMpeg from "@cycjimmy/jsmpeg-player";
import { useEffect, useRef } from "react"; import React, { useEffect, useMemo, useRef } from "react";
type JSMpegPlayerProps = { type JSMpegPlayerProps = {
className?: string; className?: string;
camera: string; camera: string;
width: number; width: number;
height: number; height: number;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
}; };
export default function JSMpegPlayer({ export default function JSMpegPlayer({
@@ -15,10 +17,65 @@ export default function JSMpegPlayer({
width, width,
height, height,
className, className,
containerRef,
}: JSMpegPlayerProps) { }: JSMpegPlayerProps) {
const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`; const url = `${baseUrl.replace(/^http/, "ws")}live/jsmpeg/${camera}`;
const playerRef = useRef<HTMLDivElement | null>(null); const playerRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const internalContainerRef = useRef<HTMLDivElement | null>(null);
const selectedContainerRef = useMemo(
() => containerRef ?? internalContainerRef,
[containerRef, internalContainerRef],
);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(selectedContainerRef);
const stretch = true;
const aspectRatio = width / height;
const fitAspect = useMemo(
() => containerWidth / containerHeight,
[containerWidth, containerHeight],
);
const scaledHeight = useMemo(() => {
if (selectedContainerRef?.current && width && height) {
const scaledHeight =
aspectRatio < (fitAspect ?? 0)
? Math.floor(
Math.min(
containerHeight,
selectedContainerRef.current?.clientHeight,
),
)
: aspectRatio >= fitAspect
? Math.floor(containerWidth / aspectRatio)
: Math.floor(containerWidth / aspectRatio) / 1.5;
const finalHeight = stretch
? scaledHeight
: Math.min(scaledHeight, height);
if (finalHeight > 0) {
return finalHeight;
}
}
}, [
aspectRatio,
containerWidth,
containerHeight,
fitAspect,
height,
width,
stretch,
selectedContainerRef,
]);
const scaledWidth = useMemo(() => {
if (aspectRatio && scaledHeight) {
return Math.ceil(scaledHeight * aspectRatio);
}
}, [scaledHeight, aspectRatio]);
useEffect(() => { useEffect(() => {
if (!playerRef.current) { if (!playerRef.current) {
@@ -28,7 +85,7 @@ export default function JSMpegPlayer({
const video = new JSMpeg.VideoElement( const video = new JSMpeg.VideoElement(
playerRef.current, playerRef.current,
url, url,
{}, { canvas: `#${camera}-canvas` },
{ protocols: [], audio: false, videoBufferSize: 1024 * 1024 * 4 }, { protocols: [], audio: false, videoBufferSize: 1024 * 1024 * 4 },
); );
@@ -41,15 +98,21 @@ export default function JSMpegPlayer({
playerRef.current = null; playerRef.current = null;
} }
}; };
}, [url]); }, [url, camera]);
return ( return (
<div className={className} ref={containerRef}> <div className={className}>
<div <div className="size-full" ref={internalContainerRef}>
ref={playerRef} <div ref={playerRef} className="jsmpeg">
className="jsmpeg h-full" <canvas
style={{ aspectRatio: width / height }} id={`${camera}-canvas`}
/> style={{
width: scaledWidth ?? width,
height: scaledHeight ?? height,
}}
></canvas>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -8,7 +8,11 @@ import JSMpegPlayer from "./JSMpegPlayer";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useCameraActivity } from "@/hooks/use-camera-activity"; import { useCameraActivity } from "@/hooks/use-camera-activity";
import { LivePlayerMode, VideoResolutionType } from "@/types/live"; import {
LivePlayerError,
LivePlayerMode,
VideoResolutionType,
} from "@/types/live";
import useCameraLiveMode from "@/hooks/use-camera-live-mode"; 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";
@@ -17,6 +21,7 @@ import { cn } from "@/lib/utils";
type LivePlayerProps = { type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void; cameraRef?: (ref: HTMLDivElement | null) => void;
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
className?: string; className?: string;
cameraConfig: CameraConfig; cameraConfig: CameraConfig;
preferredLiveMode?: LivePlayerMode; preferredLiveMode?: LivePlayerMode;
@@ -26,12 +31,15 @@ type LivePlayerProps = {
micEnabled?: boolean; // only webrtc supports mic micEnabled?: boolean; // only webrtc supports mic
iOSCompatFullScreen?: boolean; iOSCompatFullScreen?: boolean;
pip?: boolean; pip?: boolean;
autoLive?: boolean;
onClick?: () => void; onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
}; };
export default function LivePlayer({ export default function LivePlayer({
cameraRef = undefined, cameraRef = undefined,
containerRef,
className, className,
cameraConfig, cameraConfig,
preferredLiveMode, preferredLiveMode,
@@ -41,12 +49,14 @@ export default function LivePlayer({
micEnabled = false, micEnabled = false,
iOSCompatFullScreen = false, iOSCompatFullScreen = false,
pip, pip,
autoLive = true,
onClick, onClick,
setFullResolution, setFullResolution,
onError,
}: LivePlayerProps) { }: LivePlayerProps) {
// camera activity // camera activity
const { activeMotion, activeTracking, objects } = const { activeMotion, activeTracking, objects, offline } =
useCameraActivity(cameraConfig); useCameraActivity(cameraConfig);
const cameraActive = useMemo( const cameraActive = useMemo(
@@ -62,6 +72,10 @@ export default function LivePlayer({
const [liveReady, setLiveReady] = useState(false); const [liveReady, setLiveReady] = useState(false);
useEffect(() => { useEffect(() => {
if (!autoLive) {
return;
}
if (!liveReady) { if (!liveReady) {
if (cameraActive && liveMode == "jsmpeg") { if (cameraActive && liveMode == "jsmpeg") {
setLiveReady(true); setLiveReady(true);
@@ -75,12 +89,12 @@ export default function LivePlayer({
} }
// live mode won't change // live mode won't change
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraActive, liveReady]); }, [autoLive, cameraActive, liveReady]);
// camera still state // camera still state
const stillReloadInterval = useMemo(() => { const stillReloadInterval = useMemo(() => {
if (!windowVisible) { if (!windowVisible || offline) {
return -1; // no reason to update the image when the window is not visible return -1; // no reason to update the image when the window is not visible
} }
@@ -88,19 +102,32 @@ export default function LivePlayer({
return 60000; return 60000;
} }
if (cameraActive) { if (activeMotion || activeTracking) {
return 200; if (autoLive) {
return 200;
} else {
return 59000;
}
} }
return 30000; return 30000;
}, [liveReady, cameraActive, windowVisible]); }, [
autoLive,
liveReady,
activeMotion,
activeTracking,
offline,
windowVisible,
]);
if (!cameraConfig) { if (!cameraConfig) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
let player; let player;
if (liveMode == "webrtc") { if (!autoLive) {
player = null;
} else if (liveMode == "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"}`}
@@ -124,6 +151,7 @@ export default function LivePlayer({
onPlaying={() => setLiveReady(true)} onPlaying={() => setLiveReady(true)}
pip={pip} pip={pip}
setFullResolution={setFullResolution} setFullResolution={setFullResolution}
onError={onError}
/> />
); );
} else { } else {
@@ -135,14 +163,19 @@ export default function LivePlayer({
); );
} }
} else if (liveMode == "jsmpeg") { } else if (liveMode == "jsmpeg") {
player = ( if (cameraActive || !showStillWithoutActivity) {
<JSMpegPlayer player = (
className="flex size-full justify-center overflow-hidden rounded-lg md:rounded-2xl" <JSMpegPlayer
camera={cameraConfig.live.stream_name} className="flex justify-center overflow-hidden rounded-lg md:rounded-2xl"
width={cameraConfig.detect.width} camera={cameraConfig.live.stream_name}
height={cameraConfig.detect.height} width={cameraConfig.detect.width}
/> height={cameraConfig.detect.height}
); containerRef={containerRef}
/>
);
} else {
player = null;
}
} else { } else {
player = <ActivityIndicator />; player = <ActivityIndicator />;
} }
@@ -152,9 +185,7 @@ export default function LivePlayer({
ref={cameraRef} ref={cameraRef}
data-camera={cameraConfig.name} data-camera={cameraConfig.name}
className={cn( className={cn(
"relative flex justify-center", "relative flex w-full cursor-pointer justify-center outline",
liveMode === "jsmpeg" ? "size-full" : "w-full",
"cursor-pointer outline",
activeTracking activeTracking
? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl" ? "outline-3 rounded-lg shadow-severity_alert outline-severity_alert md:rounded-2xl"
: "outline-0 outline-background", : "outline-0 outline-background",
@@ -224,9 +255,16 @@ export default function LivePlayer({
/> />
</div> </div>
<div className="absolute right-2 top-2 size-4"> <div className="absolute right-2 top-2">
{activeMotion && ( {autoLive && !offline && activeMotion && (
<MdCircle className="size-2 animate-pulse text-danger shadow-danger drop-shadow-md" /> <MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
)}
{offline && (
<Chip
className={`z-0 flex items-start justify-between space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`}
>
{cameraConfig.name.replaceAll("_", " ")}
</Chip>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { VideoResolutionType } from "@/types/live"; import { LivePlayerError, VideoResolutionType } from "@/types/live";
import { import {
SetStateAction, SetStateAction,
useCallback, useCallback,
@@ -17,6 +17,7 @@ type MSEPlayerProps = {
pip?: boolean; pip?: boolean;
onPlaying?: () => void; onPlaying?: () => void;
setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void;
}; };
function MSEPlayer({ function MSEPlayer({
@@ -27,6 +28,7 @@ function MSEPlayer({
pip = false, pip = false,
onPlaying, onPlaying,
setFullResolution, setFullResolution,
onError,
}: MSEPlayerProps) { }: MSEPlayerProps) {
const RECONNECT_TIMEOUT: number = 30000; const RECONNECT_TIMEOUT: number = 30000;
@@ -45,6 +47,7 @@ function MSEPlayer({
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);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@@ -303,10 +306,39 @@ function MSEPlayer({
className={className} className={className}
playsInline playsInline
preload="auto" preload="auto"
onLoadedData={onPlaying} onLoadedData={() => {
onLoadedMetadata={handleLoadedMetadata} handleLoadedMetadata?.();
onPlaying?.();
}}
muted={!audioEnabled} muted={!audioEnabled}
onError={() => { onProgress={
onError != undefined
? () => {
if (videoRef.current?.paused) {
return;
}
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
setBufferTimeout(
setTimeout(() => {
onError("stalled");
}, 3000),
);
}
: undefined
}
onError={(e) => {
if (
// @ts-expect-error code does exist
e.target.error.code == MediaError.MEDIA_ERR_NETWORK
) {
onError?.("startup");
}
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close(); wsRef.current.close();
wsRef.current = null; wsRef.current = null;

View File

@@ -10,7 +10,7 @@ import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview"; import { Preview } from "@/types/preview";
import { PreviewPlayback } from "@/types/playback"; import { PreviewPlayback } from "@/types/playback";
import { getTimestampOffset, isCurrentHour } from "@/utils/dateUtil"; import { isCurrentHour } from "@/utils/dateUtil";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { isAndroid, isChrome, isMobile } from "react-device-detect"; import { isAndroid, isChrome, isMobile } from "react-device-detect";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
@@ -41,13 +41,11 @@ export default function PreviewPlayer({
const [currentHourFrame, setCurrentHourFrame] = useState<string>(); const [currentHourFrame, setCurrentHourFrame] = useState<string>();
const currentPreview = useMemo(() => { const currentPreview = useMemo(() => {
const timeRangeOffset = getTimestampOffset(timeRange.before);
return cameraPreviews.find( return cameraPreviews.find(
(preview) => (preview) =>
preview.camera == camera && preview.camera == camera &&
Math.round(preview.start) >= timeRange.after + timeRangeOffset && Math.round(preview.start) >= timeRange.after &&
Math.floor(preview.end) <= timeRange.before + timeRangeOffset, Math.floor(preview.end) <= timeRange.before,
); );
}, [cameraPreviews, camera, timeRange]); }, [cameraPreviews, camera, timeRange]);
@@ -197,6 +195,7 @@ function PreviewVideoPlayer({
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [videoSize, setVideoSize] = useState<number[]>([0, 0]); const [videoSize, setVideoSize] = useState<number[]>([0, 0]);
const [changeoverTimeout, setChangeoverTimeout] = useState<NodeJS.Timeout>();
const changeSource = useCallback( const changeSource = useCallback(
(newPreview: Preview | undefined, video: HTMLVideoElement | null) => { (newPreview: Preview | undefined, video: HTMLVideoElement | null) => {
@@ -220,6 +219,15 @@ function PreviewVideoPlayer({
} }
setCurrentPreview(newPreview); setCurrentPreview(newPreview);
const timeout = setTimeout(() => {
if (timeout) {
clearTimeout(timeout);
setChangeoverTimeout(undefined);
}
previewRef.current?.load();
}, 1000);
setChangeoverTimeout(timeout);
// we only want this to change when current preview changes // we only want this to change when current preview changes
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -232,17 +240,15 @@ function PreviewVideoPlayer({
return; return;
} }
// account for minutes offset in timezone
const timeRangeOffset = getTimestampOffset(timeRange.before);
const preview = cameraPreviews.find( const preview = cameraPreviews.find(
(preview) => (preview) =>
preview.camera == camera && preview.camera == camera &&
Math.round(preview.start) >= timeRange.after + timeRangeOffset && Math.round(preview.start) >= timeRange.after &&
Math.floor(preview.end) <= timeRange.before + timeRangeOffset, Math.floor(preview.end) <= timeRange.before,
); );
if (preview != currentPreview) { if (preview != currentPreview) {
controller.newPreviewLoaded = false;
changeSource(preview, previewRef.current); changeSource(preview, previewRef.current);
} }
@@ -267,7 +273,14 @@ function PreviewVideoPlayer({
<img <img
className={`absolute size-full object-contain ${currentHourFrame ? "visible" : "invisible"}`} className={`absolute size-full object-contain ${currentHourFrame ? "visible" : "invisible"}`}
src={currentHourFrame} src={currentHourFrame}
onLoad={() => previewRef.current?.load()} onLoad={() => {
if (changeoverTimeout) {
clearTimeout(changeoverTimeout);
setChangeoverTimeout(undefined);
}
previewRef.current?.load();
}}
/> />
<video <video
ref={previewRef} ref={previewRef}
@@ -302,7 +315,10 @@ function PreviewVideoPlayer({
}} }}
> >
{currentPreview != undefined && ( {currentPreview != undefined && (
<source src={currentPreview.src} type={currentPreview.type} /> <source
src={`${baseUrl}${currentPreview.src.substring(1)}`}
type={currentPreview.type}
/>
)} )}
</video> </video>
{cameraPreviews && !currentPreview && ( {cameraPreviews && !currentPreview && (
@@ -324,6 +340,7 @@ class PreviewVideoController extends PreviewController {
private preview: Preview | undefined = undefined; private preview: Preview | undefined = undefined;
private timeToSeek: number | undefined = undefined; private timeToSeek: number | undefined = undefined;
public scrubbing = false; public scrubbing = false;
public newPreviewLoaded = true;
private seeking = false; private seeking = false;
constructor( constructor(
@@ -342,7 +359,12 @@ class PreviewVideoController extends PreviewController {
} }
override scrubToTimestamp(time: number): boolean { override scrubToTimestamp(time: number): boolean {
if (!this.previewRef.current || !this.preview || !this.timeRange) { if (
!this.newPreviewLoaded ||
!this.previewRef.current ||
!this.preview ||
!this.timeRange
) {
return false; return false;
} }
@@ -393,6 +415,7 @@ class PreviewVideoController extends PreviewController {
} }
previewReady() { previewReady() {
this.newPreviewLoaded = true;
this.seeking = false; this.seeking = false;
this.previewRef.current?.pause(); this.previewRef.current?.pause();

View File

@@ -25,6 +25,7 @@ import { 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";
import { baseUrl } from "@/api/baseUrl";
type PreviewPlayerProps = { type PreviewPlayerProps = {
review: ReviewSegment; review: ReviewSegment;
@@ -575,7 +576,10 @@ export function VideoPreview({
muted muted
onTimeUpdate={onProgress} onTimeUpdate={onProgress}
> >
<source src={relevantPreview.src} type={relevantPreview.type} /> <source
src={`${baseUrl}${relevantPreview.src.substring(1)}`}
type={relevantPreview.type}
/>
</video> </video>
{showProgress && ( {showProgress && (
<NoThumbSlider <NoThumbSlider
@@ -668,7 +672,9 @@ export function InProgressPreview({
setReviewed(review.id); setReviewed(review.id);
} }
setKey(key + 1); if (previewFrames[key + 1]) {
setKey(key + 1);
}
}, MIN_LOAD_TIMEOUT_MS); }, MIN_LOAD_TIMEOUT_MS);
// we know that these deps are correct // we know that these deps are correct

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { isMobileOnly, isSafari } from "react-device-detect"; import { isMobileOnly, isSafari } from "react-device-detect";
import { LuPause, LuPlay } from "react-icons/lu"; import { LuPause, LuPlay } from "react-icons/lu";
import { import {
@@ -68,7 +68,7 @@ type VideoControlsProps = {
onSeek: (diff: number) => void; onSeek: (diff: number) => void;
onSetPlaybackRate: (rate: number) => void; onSetPlaybackRate: (rate: number) => void;
onUploadFrame?: () => void; onUploadFrame?: () => void;
setFullscreen?: (full: boolean) => void; toggleFullscreen?: () => void;
}; };
export default function VideoControls({ export default function VideoControls({
className, className,
@@ -88,8 +88,14 @@ export default function VideoControls({
onSeek, onSeek,
onSetPlaybackRate, onSetPlaybackRate,
onUploadFrame, onUploadFrame,
setFullscreen, toggleFullscreen,
}: VideoControlsProps) { }: VideoControlsProps) {
// layout
const containerRef = useRef<HTMLDivElement | null>(null);
// controls
const onReplay = useCallback( const onReplay = useCallback(
(e: React.MouseEvent<SVGElement>) => { (e: React.MouseEvent<SVGElement>) => {
e.stopPropagation(); e.stopPropagation();
@@ -133,6 +139,11 @@ export default function VideoControls({
const onKeyboardShortcut = useCallback( const onKeyboardShortcut = useCallback(
(key: string, down: boolean, repeat: boolean) => { (key: string, down: boolean, repeat: boolean) => {
switch (key) { switch (key) {
case "ArrowDown":
if (down) {
onSeek(-1);
}
break;
case "ArrowLeft": case "ArrowLeft":
if (down) { if (down) {
onSeek(-10); onSeek(-10);
@@ -143,9 +154,14 @@ export default function VideoControls({
onSeek(10); onSeek(10);
} }
break; break;
case "ArrowUp":
if (down) {
onSeek(1);
}
break;
case "f": case "f":
if (setFullscreen && down && !repeat) { if (toggleFullscreen && down && !repeat) {
setFullscreen(!fullscreen); toggleFullscreen();
} }
break; break;
case "m": case "m":
@@ -162,10 +178,12 @@ export default function VideoControls({
}, },
// only update when preview only changes // only update when preview only changes
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[video, isPlaying, fullscreen, setFullscreen, onSeek], [video, isPlaying, fullscreen, toggleFullscreen, onSeek],
); );
useKeyboardListener( useKeyboardListener(
hotKeys ? ["ArrowLeft", "ArrowRight", "f", "m", " "] : [], hotKeys
? ["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", "f", "m", " "]
: [],
onKeyboardShortcut, onKeyboardShortcut,
); );
@@ -183,6 +201,7 @@ export default function VideoControls({
MIN_ITEMS_WRAP && MIN_ITEMS_WRAP &&
"min-w-[75%] flex-wrap", "min-w-[75%] flex-wrap",
)} )}
ref={containerRef}
> >
{video && features.volume && ( {video && features.volume && (
<div className="flex cursor-pointer items-center justify-normal gap-2"> <div className="flex cursor-pointer items-center justify-normal gap-2">
@@ -223,6 +242,7 @@ export default function VideoControls({
)} )}
{features.playbackRate && ( {features.playbackRate && (
<DropdownMenu <DropdownMenu
modal={false}
onOpenChange={(open) => { onOpenChange={(open) => {
if (setControlsOpen) { if (setControlsOpen) {
setControlsOpen(open); setControlsOpen(open);
@@ -230,7 +250,9 @@ export default function VideoControls({
}} }}
> >
<DropdownMenuTrigger>{`${playbackRate}x`}</DropdownMenuTrigger> <DropdownMenuTrigger>{`${playbackRate}x`}</DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent
portalProps={{ container: containerRef.current }}
>
<DropdownMenuRadioGroup <DropdownMenuRadioGroup
onValueChange={(rate) => onSetPlaybackRate(parseFloat(rate))} onValueChange={(rate) => onSetPlaybackRate(parseFloat(rate))}
> >
@@ -265,11 +287,8 @@ export default function VideoControls({
onUploadFrame={onUploadFrame} onUploadFrame={onUploadFrame}
/> />
)} )}
{features.fullscreen && setFullscreen && ( {features.fullscreen && toggleFullscreen && (
<div <div className="cursor-pointer" onClick={toggleFullscreen}>
className="cursor-pointer"
onClick={() => setFullscreen(!fullscreen)}
>
{fullscreen ? <FaCompress /> : <FaExpand />} {fullscreen ? <FaCompress /> : <FaExpand />}
</div> </div>
)} )}
@@ -321,11 +340,11 @@ function FrigatePlusUploadButton({
}} }}
/> />
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent className="md:max-w-[80%]"> <AlertDialogContent className="md:max-w-2xl lg:max-w-3xl xl:max-w-4xl">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Submit this frame to Frigate+?</AlertDialogTitle> <AlertDialogTitle>Submit this frame to Frigate+?</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<img className="w-full object-contain" src={videoImg} /> <img className="aspect-video w-full object-contain" src={videoImg} />
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogAction className="bg-selected" onClick={onUploadFrame}> <AlertDialogAction className="bg-selected" onClick={onUploadFrame}>
Submit Submit

View File

@@ -1,4 +1,5 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { LivePlayerError } from "@/types/live";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type WebRtcPlayerProps = { type WebRtcPlayerProps = {
@@ -10,6 +11,7 @@ type WebRtcPlayerProps = {
iOSCompatFullScreen?: boolean; // ios doesn't support fullscreen divs so we must support the video element iOSCompatFullScreen?: boolean; // ios doesn't support fullscreen divs so we must support the video element
pip?: boolean; pip?: boolean;
onPlaying?: () => void; onPlaying?: () => void;
onError?: (error: LivePlayerError) => void;
}; };
export default function WebRtcPlayer({ export default function WebRtcPlayer({
@@ -21,6 +23,7 @@ export default function WebRtcPlayer({
iOSCompatFullScreen = false, iOSCompatFullScreen = false,
pip = false, pip = false,
onPlaying, onPlaying,
onError,
}: WebRtcPlayerProps) { }: WebRtcPlayerProps) {
// metadata // metadata
@@ -32,6 +35,7 @@ export default function WebRtcPlayer({
const pcRef = useRef<RTCPeerConnection | undefined>(); const pcRef = useRef<RTCPeerConnection | undefined>();
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const [bufferTimeout, setBufferTimeout] = useState<NodeJS.Timeout>();
const PeerConnection = useCallback( const PeerConnection = useCallback(
async (media: string) => { async (media: string) => {
@@ -198,11 +202,39 @@ export default function WebRtcPlayer({
playsInline playsInline
muted={!audioEnabled} muted={!audioEnabled}
onLoadedData={onPlaying} onLoadedData={onPlaying}
onProgress={
onError != undefined
? () => {
if (videoRef.current?.paused) {
return;
}
if (bufferTimeout) {
clearTimeout(bufferTimeout);
setBufferTimeout(undefined);
}
setBufferTimeout(
setTimeout(() => {
onError("stalled");
}, 3000),
);
}
: undefined
}
onClick={ onClick={
iOSCompatFullScreen iOSCompatFullScreen
? () => setiOSCompatControls(!iOSCompatControls) ? () => setiOSCompatControls(!iOSCompatControls)
: undefined : undefined
} }
onError={(e) => {
if (
// @ts-expect-error code does exist
e.target.error.code == MediaError.MEDIA_ERR_NETWORK
) {
onError?.("startup");
}
}}
/> />
); );
} }

View File

@@ -29,7 +29,7 @@ type DynamicVideoPlayerProps = {
onTimestampUpdate?: (timestamp: number) => void; onTimestampUpdate?: (timestamp: number) => void;
onClipEnded?: () => void; onClipEnded?: () => void;
setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
setFullscreen: (full: boolean) => void; toggleFullscreen: () => void;
}; };
export default function DynamicVideoPlayer({ export default function DynamicVideoPlayer({
className, className,
@@ -44,7 +44,7 @@ export default function DynamicVideoPlayer({
onTimestampUpdate, onTimestampUpdate,
onClipEnded, onClipEnded,
setFullResolution, setFullResolution,
setFullscreen, toggleFullscreen,
}: DynamicVideoPlayerProps) { }: DynamicVideoPlayerProps) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@@ -207,7 +207,7 @@ export default function DynamicVideoPlayer({
}} }}
setFullResolution={setFullResolution} setFullResolution={setFullResolution}
onUploadFrame={onUploadFrameToPlus} onUploadFrame={onUploadFrameToPlus}
setFullscreen={setFullscreen} toggleFullscreen={toggleFullscreen}
/> />
<PreviewPlayer <PreviewPlayer
className={cn( className={cn(

View File

@@ -135,9 +135,12 @@ export default function MotionMaskEditPane({
}) })
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success(`${polygon.name || "Motion Mask"} has been saved.`, { toast.success(
position: "top-center", `${polygon.name || "Motion Mask"} has been saved. Restart Frigate to apply changes.`,
}); {
position: "top-center",
},
);
updateConfig(); updateConfig();
} else { } else {
toast.error(`Failed to save config changes: ${res.statusText}`, { toast.error(`Failed to save config changes: ${res.statusText}`, {

View File

@@ -193,9 +193,12 @@ export default function ObjectMaskEditPane({
}) })
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success(`${polygon.name || "Object Mask"} has been saved.`, { toast.success(
position: "top-center", `${polygon.name || "Object Mask"} has been saved. Restart Frigate to apply changes.`,
}); {
position: "top-center",
},
);
updateConfig(); updateConfig();
} else { } else {
toast.error(`Failed to save config changes: ${res.statusText}`, { toast.error(`Failed to save config changes: ${res.statusText}`, {

View File

@@ -266,7 +266,7 @@ export default function PolygonItem({
{isMobile && ( {isMobile && (
<> <>
<DropdownMenu> <DropdownMenu modal={false}>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" /> <HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@@ -261,9 +261,12 @@ export default function ZoneEditPane({
) )
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success(`Zone (${zoneName}) has been saved.`, { toast.success(
position: "top-center", `Zone (${zoneName}) has been saved. Restart Frigate to apply changes.`,
}); {
position: "top-center",
},
);
updateConfig(); updateConfig();
} else { } else {
toast.error(`Failed to save config changes: ${res.statusText}`, { toast.error(`Failed to save config changes: ${res.statusText}`, {

View File

@@ -7,6 +7,7 @@ import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils"; import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import useTapUtils from "@/hooks/use-tap-utils"; import useTapUtils from "@/hooks/use-tap-utils";
import { cn } from "@/lib/utils";
type MotionSegmentProps = { type MotionSegmentProps = {
events: ReviewSegment[]; events: ReviewSegment[];
@@ -170,7 +171,16 @@ export function MotionSegment({
<div <div
key={segmentKey} key={segmentKey}
data-segment-id={segmentKey} data-segment-id={segmentKey}
className={`segment ${firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0 ? "has-data" : ""} ${segmentClasses} bg-gradient-to-r ${severityColorsBg[severity[0]]}`} className={cn(
"segment",
{
"has-data":
firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0,
},
segmentClasses,
severity[0] && "bg-gradient-to-r",
severity[0] && severityColorsBg[severity[0]],
)}
onClick={segmentClick} onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)} onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
> >
@@ -210,7 +220,14 @@ export function MotionSegment({
<div <div
key={`${segmentKey}_motion_data_1`} key={`${segmentKey}_motion_data_1`}
data-motion-value={secondHalfSegmentWidth} data-motion-value={secondHalfSegmentWidth}
className={`${isDesktop && animationClassesSecondHalf} h-[2px] rounded-full bg-motion_review`} className={cn(
isDesktop && animationClassesSecondHalf,
"h-[2px]",
"rounded-full",
secondHalfSegmentWidth
? "bg-motion_review"
: "bg-muted-foreground",
)}
style={{ style={{
width: secondHalfSegmentWidth || 1, width: secondHalfSegmentWidth || 1,
}} }}
@@ -223,7 +240,14 @@ export function MotionSegment({
<div <div
key={`${segmentKey}_motion_data_2`} key={`${segmentKey}_motion_data_2`}
data-motion-value={firstHalfSegmentWidth} data-motion-value={firstHalfSegmentWidth}
className={`${isDesktop && animationClassesFirstHalf} h-[2px] rounded-full bg-motion_review`} className={cn(
isDesktop && animationClassesFirstHalf,
"h-[2px]",
"rounded-full",
firstHalfSegmentWidth
? "bg-motion_review"
: "bg-muted-foreground",
)}
style={{ style={{
width: firstHalfSegmentWidth || 1, width: firstHalfSegmentWidth || 1,
}} }}

View File

@@ -258,9 +258,9 @@ export function ReviewTimeline({
if (isDragging && isMobile && draggableElementType === draggableElement) { if (isDragging && isMobile && draggableElementType === draggableElement) {
return "text-lg"; return "text-lg";
} else if (dense) { } else if (dense) {
return "text-[8px] md:text-xs"; return "text-[8px] md:text-[11px]";
} else { } else {
return "text-xs"; return "text-[11px]";
} }
}, },
[dense, isDragging, draggableElementType], [dense, isDragging, draggableElementType],
@@ -350,7 +350,7 @@ export function ReviewTimeline({
dense dense
? "w-12 md:w-20" ? "w-12 md:w-20"
: segmentDuration < 60 : segmentDuration < 60
? "w-24" ? "w-[80px]"
: "w-20" : "w-20"
} h-5 ${isDraggingHandlebar && isMobile ? "fixed left-1/2 top-[18px] z-20 h-[30px] w-32 -translate-x-1/2 transform bg-destructive/80" : "static"} flex items-center justify-center`} } h-5 ${isDraggingHandlebar && isMobile ? "fixed left-1/2 top-[18px] z-20 h-[30px] w-32 -translate-x-1/2 transform bg-destructive/80" : "static"} flex items-center justify-center`}
> >
@@ -388,7 +388,7 @@ export function ReviewTimeline({
dense dense
? "w-12 md:w-20" ? "w-12 md:w-20"
: segmentDuration < 60 : segmentDuration < 60
? "w-24" ? "w-[80px]"
: "w-20" : "w-20"
} h-5 ${isDraggingExportEnd && isMobile ? "fixed left-1/2 top-[18px] z-20 mt-0 h-[30px] w-32 -translate-x-1/2 transform rounded-full bg-selected/80" : "static rounded-tl-lg rounded-tr-lg"} flex items-center justify-center`} } h-5 ${isDraggingExportEnd && isMobile ? "fixed left-1/2 top-[18px] z-20 mt-0 h-[30px] w-32 -translate-x-1/2 transform rounded-full bg-selected/80" : "static rounded-tl-lg rounded-tr-lg"} flex items-center justify-center`}
> >
@@ -430,7 +430,7 @@ export function ReviewTimeline({
dense dense
? "w-12 md:w-20" ? "w-12 md:w-20"
: segmentDuration < 60 : segmentDuration < 60
? "w-24" ? "w-[80px]"
: "w-20" : "w-20"
} h-5 ${isDraggingExportStart && isMobile ? "fixed left-1/2 top-[4px] z-20 mt-0 h-[30px] w-32 -translate-x-1/2 transform rounded-full bg-selected/80" : "static rounded-bl-lg rounded-br-lg"} flex items-center justify-center`} } h-5 ${isDraggingExportStart && isMobile ? "fixed left-1/2 top-[4px] z-20 mt-0 h-[30px] w-32 -translate-x-1/2 transform rounded-full bg-selected/80" : "static rounded-bl-lg rounded-br-lg"} flex items-center justify-center`}
> >

View File

@@ -56,9 +56,11 @@ DropdownMenuSubContent.displayName =
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
>(({ className, sideOffset = 4, ...props }, ref) => ( portalProps?: DropdownMenuPrimitive.DropdownMenuPortalProps;
<DropdownMenuPrimitive.Portal> }
>(({ className, portalProps, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}

View File

@@ -10,11 +10,13 @@ import { useTimelineUtils } from "./use-timeline-utils";
import { ObjectType } from "@/types/ws"; import { ObjectType } from "@/types/ws";
import useDeepMemo from "./use-deep-memo"; import useDeepMemo from "./use-deep-memo";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
import { useAutoFrigateStats } from "./use-stats";
type useCameraActivityReturn = { type useCameraActivityReturn = {
activeTracking: boolean; activeTracking: boolean;
activeMotion: boolean; activeMotion: boolean;
objects: ObjectType[]; objects: ObjectType[];
offline: boolean;
}; };
export function useCameraActivity( export function useCameraActivity(
@@ -116,12 +118,31 @@ export function useCameraActivity(
handleSetObjects(newObjects); handleSetObjects(newObjects);
}, [camera, updatedEvent, objects, handleSetObjects]); }, [camera, updatedEvent, objects, handleSetObjects]);
// determine if camera is offline
const stats = useAutoFrigateStats();
const offline = useMemo(() => {
if (!stats) {
return false;
}
const cameras = stats["cameras"];
if (!cameras) {
return false;
}
return cameras[camera.name].camera_fps == 0;
}, [camera, stats]);
return { return {
activeTracking: hasActiveObjects, activeTracking: hasActiveObjects,
activeMotion: detectingMotion activeMotion: detectingMotion
? detectingMotion === "ON" ? detectingMotion === "ON"
: initialCameraState?.motion === true, : initialCameraState?.motion === true,
objects, objects,
offline,
}; };
} }

View File

@@ -6,7 +6,7 @@ import { LivePlayerMode } from "@/types/live";
export default function useCameraLiveMode( export default function useCameraLiveMode(
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
preferredMode?: string, preferredMode?: LivePlayerMode,
): LivePlayerMode | undefined { ): LivePlayerMode | undefined {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@@ -23,18 +23,16 @@ export default function useCameraLiveMode(
); );
}, [config, cameraConfig]); }, [config, cameraConfig]);
const defaultLiveMode = useMemo<LivePlayerMode | undefined>(() => { const defaultLiveMode = useMemo<LivePlayerMode | undefined>(() => {
if (config && cameraConfig) { if (config) {
if (restreamEnabled) { if (restreamEnabled) {
return cameraConfig.ui.live_mode || config.ui.live_mode; return preferredMode || "mse";
} }
return "jsmpeg"; return "jsmpeg";
} }
return undefined; return undefined;
// config will be updated if camera config is updated }, [config, preferredMode, restreamEnabled]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraConfig, restreamEnabled]);
const [viewSource] = usePersistence<LivePlayerMode>( const [viewSource] = usePersistence<LivePlayerMode>(
`${cameraConfig.name}-source`, `${cameraConfig.name}-source`,
defaultLiveMode, defaultLiveMode,

View File

@@ -1,4 +1,7 @@
import { RefObject, useCallback, useEffect, useState } from "react"; import { RefObject, useCallback, useEffect, useState } from "react";
import nosleep from "nosleep.js";
const NoSleep = new nosleep();
function getFullscreenElement(): HTMLElement | null { function getFullscreenElement(): HTMLElement | null {
return ( return (
@@ -96,9 +99,11 @@ export function useFullscreen<T extends HTMLElement = HTMLElement>(
const toggleFullscreen = useCallback(async () => { const toggleFullscreen = useCallback(async () => {
try { try {
if (!getFullscreenElement()) { if (!getFullscreenElement()) {
NoSleep.enable();
await enterFullScreen(elementRef.current!); await enterFullScreen(elementRef.current!);
} else { } else {
await exitFullscreen(); await exitFullscreen();
NoSleep.disable();
} }
setError(null); setError(null);
} catch (err) { } catch (err) {

View File

@@ -3,11 +3,11 @@ import { useState, useEffect, useCallback, useRef } from "react";
type OptimisticStateResult<T> = [T, (newValue: T) => void]; type OptimisticStateResult<T> = [T, (newValue: T) => void];
const useOptimisticState = <T>( const useOptimisticState = <T>(
initialState: T, currentState: T,
setState: (newValue: T) => void, setState: (newValue: T) => void,
delay: number = 20, delay: number = 20,
): OptimisticStateResult<T> => { ): OptimisticStateResult<T> => {
const [optimisticValue, setOptimisticValue] = useState<T>(initialState); const [optimisticValue, setOptimisticValue] = useState<T>(currentState);
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleValueChange = useCallback( const handleValueChange = useCallback(
@@ -37,6 +37,16 @@ const useOptimisticState = <T>(
}; };
}, []); }, []);
useEffect(() => {
if (currentState != optimisticValue) {
setOptimisticValue(currentState);
}
// sometimes an external action will cause the currentState to change
// without handleValueChange being called. In this case
// we need to update the optimistic value so the UI reflects the change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentState]);
return [optimisticValue, handleValueChange]; return [optimisticValue, handleValueChange];
}; };

View File

@@ -9,6 +9,7 @@ import { useMemo } from "react";
import useSWR from "swr"; import useSWR from "swr";
import useDeepMemo from "./use-deep-memo"; import useDeepMemo from "./use-deep-memo";
import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { useFrigateStats } from "@/api/ws";
export default function useStats(stats: FrigateStats | undefined) { export default function useStats(stats: FrigateStats | undefined) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@@ -91,3 +92,20 @@ export default function useStats(stats: FrigateStats | undefined) {
return { potentialProblems }; return { potentialProblems };
} }
export function useAutoFrigateStats() {
const { data: initialStats } = useSWR<FrigateStats>("stats", {
revalidateOnFocus: false,
});
const { payload: latestStats } = useFrigateStats();
const stats = useMemo(() => {
if (latestStats) {
return latestStats;
}
return initialStats;
}, [initialStats, latestStats]);
return stats;
}

View File

@@ -162,3 +162,7 @@
font-display: swap; font-display: swap;
src: url("../fonts/Inter-BlackItalic.woff2") format("woff2"); src: url("../fonts/Inter-BlackItalic.woff2") format("woff2");
} }
.react-resizable-handle {
z-index: 30;
}

View File

@@ -29,7 +29,7 @@ function ConfigEditor() {
const [error, setError] = useState<string | undefined>(); const [error, setError] = useState<string | undefined>();
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null); const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const modelRef = useRef<monaco.editor.IEditorModel | null>(null); const modelRef = useRef<monaco.editor.ITextModel | null>(null);
const configRef = useRef<HTMLDivElement | null>(null); const configRef = useRef<HTMLDivElement | null>(null);
const onHandleSaveConfig = useCallback( const onHandleSaveConfig = useCallback(
@@ -124,6 +124,12 @@ function ConfigEditor() {
}; };
}); });
useEffect(() => {
if (config && modelRef.current) {
modelRef.current.setValue(config);
}
}, [config]);
if (!config) { if (!config) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }

View File

@@ -10,8 +10,8 @@ import {
ReviewSegment, ReviewSegment,
ReviewSeverity, ReviewSeverity,
ReviewSummary, ReviewSummary,
SegmentedReviewData,
} from "@/types/review"; } from "@/types/review";
import { getTimestampOffset } from "@/utils/dateUtil";
import EventView from "@/views/events/EventView"; import EventView from "@/views/events/EventView";
import { RecordingView } from "@/views/events/RecordingView"; import { RecordingView } from "@/views/events/RecordingView";
import axios from "axios"; import axios from "axios";
@@ -138,6 +138,66 @@ export default function Events() {
}, },
); );
const reviewItems = useMemo<SegmentedReviewData>(() => {
if (!reviews) {
return undefined;
}
const all: ReviewSegment[] = [];
const alerts: ReviewSegment[] = [];
const detections: ReviewSegment[] = [];
const motion: ReviewSegment[] = [];
reviews?.forEach((segment) => {
all.push(segment);
switch (segment.severity) {
case "alert":
alerts.push(segment);
break;
case "detection":
detections.push(segment);
break;
default:
motion.push(segment);
break;
}
});
return {
all: all,
alert: alerts,
detection: detections,
significant_motion: motion,
};
}, [reviews]);
const currentItems = useMemo(() => {
if (!reviewItems || !severity) {
return null;
}
let current;
if (reviewFilter?.showAll) {
current = reviewItems.all;
} else {
current = reviewItems[severity];
}
if (!current || current.length == 0) {
return [];
}
if (reviewFilter?.showReviewed != 1) {
return current.filter((seg) => !seg.has_been_reviewed);
} else {
return current;
}
// only refresh when severity or filter changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [severity, reviewFilter, reviewItems?.all.length]);
// review summary // review summary
const { data: reviewSummary, mutate: updateSummary } = useSWR<ReviewSummary>( const { data: reviewSummary, mutate: updateSummary } = useSWR<ReviewSummary>(
@@ -163,18 +223,15 @@ export default function Events() {
// preview videos // preview videos
const previewTimes = useMemo(() => { const previewTimes = useMemo(() => {
// offset by timezone minutes
const timestampOffset = getTimestampOffset(Date.now() / 1000);
const startDate = new Date(selectedTimeRange.after * 1000); const startDate = new Date(selectedTimeRange.after * 1000);
startDate.setMinutes(0, 0, 0); startDate.setUTCMinutes(0, 0, 0);
const endDate = new Date(selectedTimeRange.before * 1000); const endDate = new Date(selectedTimeRange.before * 1000);
endDate.setHours(endDate.getHours() + 1, 0, 0, 0); endDate.setHours(endDate.getHours() + 1, 0, 0, 0);
return { return {
after: startDate.getTime() / 1000 + timestampOffset, after: startDate.getTime() / 1000,
before: endDate.getTime() / 1000 + timestampOffset, before: endDate.getTime() / 1000,
}; };
}, [selectedTimeRange]); }, [selectedTimeRange]);
@@ -353,7 +410,8 @@ export default function Events() {
} else { } else {
return ( return (
<EventView <EventView
reviews={reviews} reviewItems={reviewItems}
currentReviewItems={currentItems}
reviewSummary={reviewSummary} reviewSummary={reviewSummary}
relevantPreviews={allPreviews} relevantPreviews={allPreviews}
timeRange={selectedTimeRange} timeRange={selectedTimeRange}

View File

@@ -1,3 +1,4 @@
import { useFullscreen } from "@/hooks/use-fullscreen";
import { import {
useHashState, useHashState,
usePersistedOverlayState, usePersistedOverlayState,
@@ -6,7 +7,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView"; import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
import LiveCameraView from "@/views/live/LiveCameraView"; import LiveCameraView from "@/views/live/LiveCameraView";
import LiveDashboardView from "@/views/live/LiveDashboardView"; import LiveDashboardView from "@/views/live/LiveDashboardView";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useRef } from "react";
import useSWR from "swr"; import useSWR from "swr";
function Live() { function Live() {
@@ -20,6 +21,12 @@ function Live() {
"default" as string, "default" as string,
); );
// fullscreen
const mainRef = useRef<HTMLDivElement | null>(null);
const { fullscreen, toggleFullscreen } = useFullscreen(mainRef);
// document title // document title
useEffect(() => { useEffect(() => {
@@ -78,21 +85,31 @@ function Live() {
[cameras, selectedCameraName], [cameras, selectedCameraName],
); );
if (selectedCameraName == "birdseye") {
return <LiveBirdseyeView />;
}
if (selectedCamera) {
return <LiveCameraView config={config} camera={selectedCamera} />;
}
return ( return (
<LiveDashboardView <div className="size-full" ref={mainRef}>
cameras={cameras} {selectedCameraName === "birdseye" ? (
cameraGroup={cameraGroup} <LiveBirdseyeView
includeBirdseye={includesBirdseye} fullscreen={fullscreen}
onSelectCamera={setSelectedCameraName} toggleFullscreen={toggleFullscreen}
/> />
) : selectedCamera ? (
<LiveCameraView
config={config}
camera={selectedCamera}
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
/>
) : (
<LiveDashboardView
cameras={cameras}
cameraGroup={cameraGroup}
includeBirdseye={includesBirdseye}
onSelectCamera={setSelectedCameraName}
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
/>
)}
</div>
); );
} }

View File

@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LogData, LogLine, LogSeverity } from "@/types/log"; import { LogData, LogLine, LogSeverity, LogType, logTypes } from "@/types/log";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios"; import axios from "axios";
@@ -10,25 +10,20 @@ import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter";
import { FaCopy } from "react-icons/fa6"; import { FaCopy } from "react-icons/fa6";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner"; import { toast } from "sonner";
import { isDesktop } from "react-device-detect"; import {
isDesktop,
isMobile,
isMobileOnly,
isTablet,
} from "react-device-detect";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MdVerticalAlignBottom } from "react-icons/md"; import { MdVerticalAlignBottom } from "react-icons/md";
import { parseLogLines } from "@/utils/logUtil";
const logTypes = ["frigate", "go2rtc", "nginx"] as const; import useKeyboardListener from "@/hooks/use-keyboard-listener";
type LogType = (typeof logTypes)[number];
type LogRange = { start: number; end: number }; type LogRange = { start: number; end: number };
const frigateDateStamp = /\[[\d\s-:]*]/;
const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/;
const frigateSection = /[\w.]*/;
const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/;
const goSection = /\[[\w]*]/;
const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
function Logs() { function Logs() {
const [logService, setLogService] = useState<LogType>("frigate"); const [logService, setLogService] = useState<LogType>("frigate");
@@ -38,24 +33,38 @@ function Logs() {
// log data handling // log data handling
const logPageSize = useMemo(() => {
if (isMobileOnly) {
return 15;
}
if (isTablet) {
return 25;
}
return 40;
}, []);
const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 }); const [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
const [logs, setLogs] = useState<string[]>([]); const [logs, setLogs] = useState<string[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]);
useEffect(() => { useEffect(() => {
axios axios
.get(`logs/${logService}?start=-100`) .get(`logs/${logService}?start=-${logPageSize}`)
.then((resp) => { .then((resp) => {
if (resp.status == 200) { if (resp.status == 200) {
const data = resp.data as LogData; const data = resp.data as LogData;
setLogRange({ setLogRange({
start: Math.max(0, data.totalLines - 100), start: Math.max(0, data.totalLines - logPageSize),
end: data.totalLines, end: data.totalLines,
}); });
setLogs(data.lines); setLogs(data.lines);
setLogLines(parseLogLines(logService, data.lines));
} }
}) })
.catch(() => {}); .catch(() => {});
}, [logService]); }, [logPageSize, logService]);
useEffect(() => { useEffect(() => {
if (!logs || logs.length == 0) { if (!logs || logs.length == 0) {
@@ -75,6 +84,10 @@ function Logs() {
end: data.totalLines, end: data.totalLines,
}); });
setLogs([...logs, ...data.lines]); setLogs([...logs, ...data.lines]);
setLogLines([
...logLines,
...parseLogLines(logService, data.lines),
]);
} }
} }
}) })
@@ -86,137 +99,12 @@ function Logs() {
clearTimeout(id); clearTimeout(id);
} }
}; };
}, [logs, logService, logRange]); // we need to listen on the current range of visible items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [logLines, logService, logRange]);
// convert to log data // convert to log data
const logLines = useMemo<LogLine[]>(() => {
if (!logs) {
return [];
}
if (logService == "frigate") {
return logs
.map((line) => {
const match = frigateDateStamp.exec(line);
if (!match) {
const infoIndex = line.indexOf("[INFO]");
if (infoIndex != -1) {
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: "startup",
content: line.substring(infoIndex + 6).trim(),
};
}
return {
dateStamp: line.substring(0, 19),
severity: "unknown",
section: "unknown",
content: line.substring(30).trim(),
};
}
const sectionMatch = frigateSection.exec(
line.substring(match.index + match[0].length).trim(),
);
if (!sectionMatch) {
return null;
}
return {
dateStamp: match.toString().slice(1, -1),
severity: frigateSeverity
.exec(line)
?.at(0)
?.toString()
?.toLowerCase() as LogSeverity,
section: sectionMatch.toString(),
content: line
.substring(line.indexOf(":", match.index + match[0].length) + 2)
.trim(),
};
})
.filter((value) => value != null) as LogLine[];
} else if (logService == "go2rtc") {
return logs
.map((line) => {
if (line.length == 0) {
return null;
}
const severity = goSeverity.exec(line);
let section =
goSection.exec(line)?.toString()?.slice(1, -1) ?? "startup";
if (frigateSeverity.exec(section)) {
section = "startup";
}
let contentStart;
if (section == "startup") {
if (severity) {
contentStart = severity.index + severity[0].length;
} else {
contentStart = line.lastIndexOf("]") + 1;
}
} else {
contentStart = line.indexOf(section) + section.length + 2;
}
let severityCat: LogSeverity;
switch (severity?.at(0)?.toString().trim()) {
case "INF":
severityCat = "info";
break;
case "WRN":
severityCat = "warning";
break;
case "ERR":
severityCat = "error";
break;
case "DBG":
case "TRC":
severityCat = "debug";
break;
default:
severityCat = "info";
}
return {
dateStamp: line.substring(0, 19),
severity: severityCat,
section: section,
content: line.substring(contentStart).trim(),
};
})
.filter((value) => value != null) as LogLine[];
} else if (logService == "nginx") {
return logs
.map((line) => {
if (line.length == 0) {
return null;
}
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META",
content: line.substring(line.indexOf(" ", 20)).trim(),
};
})
.filter((value) => value != null) as LogLine[];
} else {
return [];
}
}, [logs, logService]);
const handleCopyLogs = useCallback(() => { const handleCopyLogs = useCallback(() => {
if (logs) { if (logs) {
copy(logs.join("\n")); copy(logs.join("\n"));
@@ -261,31 +149,38 @@ function Logs() {
} }
try { try {
startObserver.current = new IntersectionObserver((entries) => { startObserver.current = new IntersectionObserver(
if (entries[0].isIntersecting && logRange.start > 0) { (entries) => {
const start = Math.max(0, logRange.start - 100); if (entries[0].isIntersecting && logRange.start > 0) {
const start = Math.max(0, logRange.start - logPageSize);
axios axios
.get(`logs/${logService}?start=${start}&end=${logRange.start}`) .get(`logs/${logService}?start=${start}&end=${logRange.start}`)
.then((resp) => { .then((resp) => {
if (resp.status == 200) { if (resp.status == 200) {
const data = resp.data as LogData; const data = resp.data as LogData;
if (data.lines.length > 0) { if (data.lines.length > 0) {
setLogRange({ setLogRange({
start: start, start: start,
end: logRange.end, end: logRange.end,
}); });
setLogs([...data.lines, ...logs]); setLogs([...data.lines, ...logs]);
setLogLines([
...parseLogLines(logService, data.lines),
...logLines,
]);
}
} }
} })
}) .catch(() => {});
.catch(() => {}); contentRef.current?.scrollBy({
contentRef.current?.scrollBy({ top: 10,
top: 10, });
}); }
} },
}); { rootMargin: `${10 * (isMobile ? 64 : 48)}px 0px 0px 0px` },
);
if (node) startObserver.current.observe(node); if (node) startObserver.current.observe(node);
} catch (e) { } catch (e) {
// no op // no op
@@ -332,6 +227,40 @@ function Logs() {
const [selectedLog, setSelectedLog] = useState<LogLine>(); const [selectedLog, setSelectedLog] = useState<LogLine>();
// interaction
useKeyboardListener(
["PageDown", "PageUp", "ArrowDown", "ArrowUp"],
(key, down, _) => {
if (!down) {
return;
}
switch (key) {
case "PageDown":
contentRef.current?.scrollBy({
top: 480,
});
break;
case "PageUp":
contentRef.current?.scrollBy({
top: -480,
});
break;
case "ArrowDown":
contentRef.current?.scrollBy({
top: 48,
});
break;
case "ArrowUp":
contentRef.current?.scrollBy({
top: -48,
});
break;
}
},
);
return ( return (
<div className="flex size-full flex-col p-2"> <div className="flex size-full flex-col p-2">
<Toaster position="top-center" closeButton={true} /> <Toaster position="top-center" closeButton={true} />
@@ -346,6 +275,7 @@ function Logs() {
onValueChange={(value: LogType) => { onValueChange={(value: LogType) => {
if (value) { if (value) {
setLogs([]); setLogs([]);
setLogLines([]);
setFilterSeverity(undefined); setFilterSeverity(undefined);
setLogService(value); setLogService(value);
} }

View File

@@ -17,8 +17,6 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import MotionTuner from "@/components/settings/MotionTuner";
import MasksAndZones from "@/components/settings/MasksAndZones";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useOptimisticState from "@/hooks/use-optimistic-state"; import useOptimisticState from "@/hooks/use-optimistic-state";
@@ -26,14 +24,16 @@ import { isMobile } from "react-device-detect";
import { FaVideo } from "react-icons/fa"; import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
import General from "@/components/settings/General";
import FilterSwitch from "@/components/filter/FilterSwitch"; import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
import { PolygonType } from "@/types/canvas"; import { PolygonType } from "@/types/canvas";
import ObjectSettings from "@/components/settings/ObjectSettings";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import scrollIntoView from "scroll-into-view-if-needed"; import scrollIntoView from "scroll-into-view-if-needed";
import Authentication from "@/components/settings/Authentication"; import GeneralSettingsView from "@/views/settings/GeneralSettingsView";
import ObjectSettingsView from "@/views/settings/ObjectSettingsView";
import MotionTunerView from "@/views/settings/MotionTunerView";
import MasksAndZonesView from "@/views/settings/MasksAndZonesView";
import AuthenticationView from "@/views/settings/AuthenticationView";
export default function Settings() { export default function Settings() {
const settingsViews = [ const settingsViews = [
@@ -156,22 +156,24 @@ export default function Settings() {
)} )}
</div> </div>
<div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24"> <div className="mt-2 flex h-full w-full flex-col items-start md:h-dvh md:pb-24">
{page == "general" && <General />} {page == "general" && <GeneralSettingsView />}
{page == "debug" && <ObjectSettings selectedCamera={selectedCamera} />} {page == "debug" && (
<ObjectSettingsView selectedCamera={selectedCamera} />
)}
{page == "masks / zones" && ( {page == "masks / zones" && (
<MasksAndZones <MasksAndZonesView
selectedCamera={selectedCamera} selectedCamera={selectedCamera}
selectedZoneMask={filterZoneMask} selectedZoneMask={filterZoneMask}
setUnsavedChanges={setUnsavedChanges} setUnsavedChanges={setUnsavedChanges}
/> />
)} )}
{page == "motion tuner" && ( {page == "motion tuner" && (
<MotionTuner <MotionTunerView
selectedCamera={selectedCamera} selectedCamera={selectedCamera}
setUnsavedChanges={setUnsavedChanges} setUnsavedChanges={setUnsavedChanges}
/> />
)} )}
{page == "users" && <Authentication />} {page == "users" && <AuthenticationView />}
</div> </div>
{confirmationDialogOpen && ( {confirmationDialogOpen && (
<AlertDialog <AlertDialog
@@ -282,6 +284,7 @@ function CameraSelectButton({
return ( return (
<DropdownMenu <DropdownMenu
modal={false}
open={open} open={open}
onOpenChange={(open: boolean) => { onOpenChange={(open: boolean) => {
if (!open) { if (!open) {

View File

@@ -1,5 +1,4 @@
import { IconName } from "@/components/icons/IconPicker"; import { IconName } from "@/components/icons/IconPicker";
import { LivePlayerMode } from "./live";
export interface UiConfig { export interface UiConfig {
timezone?: string; timezone?: string;
@@ -7,8 +6,6 @@ export interface UiConfig {
date_style?: "full" | "long" | "medium" | "short"; date_style?: "full" | "long" | "medium" | "short";
time_style?: "full" | "long" | "medium" | "short"; time_style?: "full" | "long" | "medium" | "short";
strftime_fmt?: string; strftime_fmt?: string;
live_mode?: LivePlayerMode;
use_experimental?: boolean;
dashboard: boolean; dashboard: boolean;
order: number; order: number;
} }

View File

@@ -30,3 +30,5 @@ export type LiveStreamMetadata = {
producers: LiveProducerMetadata[]; producers: LiveProducerMetadata[];
consumers: LiveConsumerMetadata[]; consumers: LiveConsumerMetadata[];
}; };
export type LivePlayerError = "stalled" | "startup";

View File

@@ -11,3 +11,6 @@ export type LogLine = {
section: string; section: string;
content: string; content: string;
}; };
export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
export type LogType = (typeof logTypes)[number];

View File

@@ -20,6 +20,15 @@ export type ReviewData = {
zones: string[]; zones: string[];
}; };
export type SegmentedReviewData =
| {
all: ReviewSegment[];
alert: ReviewSegment[];
detection: ReviewSegment[];
significant_motion: ReviewSegment[];
}
| undefined;
export type ReviewFilter = { export type ReviewFilter = {
cameras?: string[]; cameras?: string[];
labels?: string[]; labels?: string[];

View File

@@ -269,15 +269,6 @@ export const getUTCOffset = (
); );
}; };
/**
* Gets the minute offset in seconds of the current timezone from UTC.
* Any timezones with an offset in hours will return 0,
* any timezones with an offset of 30 or 45 minutes will return that amount in seconds.
*/
export function getTimestampOffset(timestamp: number) {
return (getUTCOffset(new Date(timestamp * 1000)) % 60) * 60;
}
export function getRangeForTimestamp(timestamp: number) { export function getRangeForTimestamp(timestamp: number) {
const date = new Date(timestamp * 1000); const date = new Date(timestamp * 1000);
date.setMinutes(0, 0, 0); date.setMinutes(0, 0, 0);
@@ -301,9 +292,7 @@ export function getEndOfDayTimestamp(date: Date) {
export function isCurrentHour(timestamp: number) { export function isCurrentHour(timestamp: number) {
const now = new Date(); const now = new Date();
now.setMinutes(0, 0, 0); now.setUTCMinutes(0, 0, 0);
const timeShift = getTimestampOffset(timestamp); return timestamp > now.getTime() / 1000;
return timestamp + timeShift > now.getTime() / 1000;
} }

View File

@@ -12,7 +12,7 @@ import {
FaFire, FaFire,
FaUps, FaUps,
} from "react-icons/fa"; } from "react-icons/fa";
import { GiHummingbird } from "react-icons/gi"; import { GiDeer, GiHummingbird } from "react-icons/gi";
import { LuBox, LuLassoSelect } from "react-icons/lu"; import { LuBox, LuLassoSelect } from "react-icons/lu";
import * as LuIcons from "react-icons/lu"; import * as LuIcons from "react-icons/lu";
import { MdRecordVoiceOver } from "react-icons/md"; import { MdRecordVoiceOver } from "react-icons/md";
@@ -38,6 +38,8 @@ export function getIconForLabel(label: string, className?: string) {
return <FaCarSide key={label} className={className} />; return <FaCarSide key={label} className={className} />;
case "cat": case "cat":
return <FaCat key={label} className={className} />; return <FaCat key={label} className={className} />;
case "deer":
return <GiDeer key={label} className={className} />;
case "animal": case "animal":
case "bark": case "bark":
case "dog": case "dog":

133
web/src/utils/logUtil.ts Normal file
View File

@@ -0,0 +1,133 @@
import { LogLine, LogSeverity, LogType } from "@/types/log";
const frigateDateStamp = /\[[\d\s-:]*]/;
const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/;
const frigateSection = /[\w.]*/;
const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/;
const goSection = /\[[\w]*]/;
const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/;
export function parseLogLines(logService: LogType, logs: string[]) {
if (logService == "frigate") {
return logs
.map((line) => {
const match = frigateDateStamp.exec(line);
if (!match) {
const infoIndex = line.indexOf("[INFO]");
if (infoIndex != -1) {
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: "startup",
content: line.substring(infoIndex + 6).trim(),
};
}
return {
dateStamp: line.substring(0, 19),
severity: "unknown",
section: "unknown",
content: line.substring(30).trim(),
};
}
const sectionMatch = frigateSection.exec(
line.substring(match.index + match[0].length).trim(),
);
if (!sectionMatch) {
return null;
}
return {
dateStamp: match.toString().slice(1, -1),
severity: frigateSeverity
.exec(line)
?.at(0)
?.toString()
?.toLowerCase() as LogSeverity,
section: sectionMatch.toString(),
content: line
.substring(line.indexOf(":", match.index + match[0].length) + 2)
.trim(),
};
})
.filter((value) => value != null) as LogLine[];
} else if (logService == "go2rtc") {
return logs
.map((line) => {
if (line.length == 0) {
return null;
}
const severity = goSeverity.exec(line);
let section =
goSection.exec(line)?.toString()?.slice(1, -1) ?? "startup";
if (frigateSeverity.exec(section)) {
section = "startup";
}
let contentStart;
if (section == "startup") {
if (severity) {
contentStart = severity.index + severity[0].length;
} else {
contentStart = line.lastIndexOf("]") + 1;
}
} else {
contentStart = line.indexOf(section) + section.length + 2;
}
let severityCat: LogSeverity;
switch (severity?.at(0)?.toString().trim()) {
case "INF":
severityCat = "info";
break;
case "WRN":
severityCat = "warning";
break;
case "ERR":
severityCat = "error";
break;
case "DBG":
case "TRC":
severityCat = "debug";
break;
default:
severityCat = "info";
}
return {
dateStamp: line.substring(0, 19),
severity: severityCat,
section: section,
content: line.substring(contentStart).trim(),
};
})
.filter((value) => value != null) as LogLine[];
} else if (logService == "nginx") {
return logs
.map((line) => {
if (line.length == 0) {
return null;
}
return {
dateStamp: line.substring(0, 19),
severity: "info",
section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META",
content: line.substring(line.indexOf(" ", 20)).trim(),
};
})
.filter((value) => value != null) as LogLine[];
}
return [];
}

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