mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-03 06:50:58 +00:00
Compare commits
17 Commits
aa7f714992
...
1aea5b695d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aea5b695d | ||
|
|
9d4aac2b8e | ||
|
|
aa09132dfd | ||
|
|
24766ce427 | ||
|
|
acb17a7b50 | ||
|
|
97b29d177a | ||
|
|
7933a83a42 | ||
|
|
2eef58aa1d | ||
|
|
6659b7cb0f | ||
|
|
f134796913 | ||
|
|
b4abbd7d3b | ||
|
|
438df7d484 | ||
|
|
e27a94ae0b | ||
|
|
1dee548dbc | ||
|
|
91e17e12b7 | ||
|
|
bb45483e9e | ||
|
|
7b4eaf2d10 |
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@ -15,7 +15,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: 3.9
|
||||
PYTHON_VERSION: 3.11
|
||||
|
||||
jobs:
|
||||
amd64_build:
|
||||
@ -225,3 +225,29 @@ jobs:
|
||||
sources: |
|
||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-amd64
|
||||
ghcr.io/${{ steps.lowercaseRepo.outputs.lowercase }}:${{ env.SHORT_SHA }}-rpi
|
||||
axera_build:
|
||||
runs-on: ubuntu-22.04
|
||||
name: AXERA Build
|
||||
needs:
|
||||
- amd64_build
|
||||
- arm64_build
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up QEMU and Buildx
|
||||
id: setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push Axera build
|
||||
uses: docker/bake-action@v6
|
||||
with:
|
||||
source: .
|
||||
push: true
|
||||
targets: axcl
|
||||
files: docker/axcl/axcl.hcl
|
||||
set: |
|
||||
axcl.tags=${{ steps.setup.outputs.image-name }}-axcl
|
||||
*.cache-from=type=gha
|
||||
53
README_CN.md
53
README_CN.md
@ -1,28 +1,31 @@
|
||||
<p align="center">
|
||||
<img align="center" alt="logo" src="docs/static/img/frigate.png">
|
||||
<img align="center" alt="logo" src="docs/static/img/branding/frigate.png">
|
||||
</p>
|
||||
|
||||
# Frigate - 一个具有实时目标检测的本地NVR
|
||||
# Frigate NVR™ - 一个具有实时目标检测的本地 NVR
|
||||
|
||||
[English](https://github.com/blakeblackshear/frigate) | \[简体中文\]
|
||||
[English](https://github.com/blakeblackshear/frigate) | \[简体中文\]
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/frigate-nvr/-/zh_Hans/">
|
||||
<img src="https://hosted.weblate.org/widget/frigate-nvr/-/zh_Hans/svg-badge.svg" alt="翻译状态" />
|
||||
</a>
|
||||
|
||||
一个完整的本地网络视频录像机(NVR),专为[Home Assistant](https://www.home-assistant.io)设计,具备AI物体检测功能。使用OpenCV和TensorFlow在本地为IP摄像头执行实时物体检测。
|
||||
一个完整的本地网络视频录像机(NVR),专为[Home Assistant](https://www.home-assistant.io)设计,具备 AI 目标/物体检测功能。使用 OpenCV 和 TensorFlow 在本地为 IP 摄像头执行实时物体检测。
|
||||
|
||||
强烈推荐使用GPU或者AI加速器(例如[Google Coral加速器](https://coral.ai/products/) 或者 [Hailo](https://hailo.ai/))。它们的性能甚至超过目前的顶级CPU,并且可以以极低的耗电实现更优的性能。
|
||||
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与Home Assistant紧密集成
|
||||
- 设计上通过仅在必要时和必要地点寻找物体,最大限度地减少资源使用并最大化性能
|
||||
强烈推荐使用 GPU 或者 AI 加速器(例如[Google Coral 加速器](https://coral.ai/products/) 或者 [Hailo](https://hailo.ai/)等)。它们的运行效率远远高于现在的顶级 CPU,并且功耗也极低。
|
||||
|
||||
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与 Home Assistant 紧密集成
|
||||
- 设计上通过仅在必要时和必要地点寻找目标,最大限度地减少资源使用并最大化性能
|
||||
- 大量利用多进程处理,强调实时性而非处理每一帧
|
||||
- 使用非常低开销的运动检测来确定运行物体检测的位置
|
||||
- 使用TensorFlow进行物体检测,运行在单独的进程中以达到最大FPS
|
||||
- 通过MQTT进行通信,便于集成到其他系统中
|
||||
- 使用非常低开销的画面变动检测(也叫运动检测)来确定运行目标检测的位置
|
||||
- 使用 TensorFlow 进行目标检测,并运行在单独的进程中以达到最大 FPS
|
||||
- 通过 MQTT 进行通信,便于集成到其他系统中
|
||||
- 根据检测到的物体设置保留时间进行视频录制
|
||||
- 24/7全天候录制
|
||||
- 通过RTSP重新流传输以减少摄像头的连接数
|
||||
- 支持WebRTC和MSE,实现低延迟的实时观看
|
||||
- 24/7 全天候录制
|
||||
- 通过 RTSP 重新流传输以减少摄像头的连接数
|
||||
- 支持 WebRTC 和 MSE,实现低延迟的实时观看
|
||||
|
||||
## 社区中文翻译文档
|
||||
|
||||
@ -32,39 +35,55 @@
|
||||
|
||||
如果您想通过捐赠支持开发,请使用 [Github Sponsors](https://github.com/sponsors/blakeblackshear)。
|
||||
|
||||
## 协议
|
||||
|
||||
本项目采用 **MIT 许可证**授权。
|
||||
**代码部分**:本代码库中的源代码、配置文件和文档均遵循 [MIT 许可证](LICENSE)。您可以自由使用、修改和分发这些代码,但必须保留原始版权声明。
|
||||
|
||||
**商标部分**:“Frigate”名称、“Frigate NVR”品牌以及 Frigate 的 Logo 为 **Frigate LLC 的商标**,**不在** MIT 许可证覆盖范围内。
|
||||
有关品牌资产的规范使用详情,请参阅我们的[《商标政策》](TRADEMARK.md)。
|
||||
|
||||
## 截图
|
||||
|
||||
### 实时监控面板
|
||||
|
||||
<div>
|
||||
<img width="800" alt="实时监控面板" src="https://github.com/blakeblackshear/frigate/assets/569905/5e713cb9-9db5-41dc-947a-6937c3bc376e">
|
||||
</div>
|
||||
|
||||
### 简单的核查工作流程
|
||||
|
||||
<div>
|
||||
<img width="800" alt="简单的审查工作流程" src="https://github.com/blakeblackshear/frigate/assets/569905/6fed96e8-3b18-40e5-9ddc-31e6f3c9f2ff">
|
||||
</div>
|
||||
|
||||
### 多摄像头可按时间轴查看
|
||||
|
||||
<div>
|
||||
<img width="800" alt="多摄像头可按时间轴查看" src="https://github.com/blakeblackshear/frigate/assets/569905/d6788a15-0eeb-4427-a8d4-80b93cae3d74">
|
||||
</div>
|
||||
|
||||
### 内置遮罩和区域编辑器
|
||||
|
||||
<div>
|
||||
<img width="800" alt="内置遮罩和区域编辑器" src="https://github.com/blakeblackshear/frigate/assets/569905/d7885fc3-bfe6-452f-b7d0-d957cb3e31f5">
|
||||
</div>
|
||||
|
||||
|
||||
## 翻译
|
||||
|
||||
我们使用 [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) 平台提供翻译支持,欢迎参与进来一起完善。
|
||||
|
||||
|
||||
## 非官方中文讨论社区
|
||||
欢迎加入中文讨论QQ群:[1043861059](https://qm.qq.com/q/7vQKsTmSz)
|
||||
|
||||
欢迎加入中文讨论 QQ 群:[1043861059](https://qm.qq.com/q/7vQKsTmSz)
|
||||
|
||||
Bilibili:https://space.bilibili.com/3546894915602564
|
||||
|
||||
|
||||
## 中文社区赞助商
|
||||
|
||||
[](https://edgeone.ai/zh?from=github)
|
||||
本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助
|
||||
|
||||
---
|
||||
|
||||
**Copyright © 2025 Frigate LLC.**
|
||||
|
||||
55
docker/axcl/Dockerfile
Normal file
55
docker/axcl/Dockerfile
Normal file
@ -0,0 +1,55 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
|
||||
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Globally set pip break-system-packages option to avoid having to specify it every time
|
||||
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
|
||||
|
||||
FROM frigate AS frigate-axcl
|
||||
ARG TARGETARCH
|
||||
ARG PIP_BREAK_SYSTEM_PACKAGES
|
||||
|
||||
# Install axpyengine
|
||||
RUN wget https://github.com/AXERA-TECH/pyaxengine/releases/download/0.1.3.rc1/axengine-0.1.3-py3-none-any.whl -O /axengine-0.1.3-py3-none-any.whl
|
||||
RUN pip3 install -i https://mirrors.aliyun.com/pypi/simple/ /axengine-0.1.3-py3-none-any.whl \
|
||||
&& rm /axengine-0.1.3-py3-none-any.whl
|
||||
|
||||
# Install axcl
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
echo "Installing x86_64 version of axcl"; \
|
||||
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_x86_64_V3.6.5_20250908154509_NO4973.deb -O /axcl.deb; \
|
||||
else \
|
||||
echo "Installing aarch64 version of axcl"; \
|
||||
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_aarch64_V3.6.5_20250908154509_NO4973.deb -O /axcl.deb; \
|
||||
fi
|
||||
|
||||
RUN mkdir /unpack_axcl && \
|
||||
dpkg-deb -x /axcl.deb /unpack_axcl && \
|
||||
cp -R /unpack_axcl/usr/bin/axcl /usr/bin/ && \
|
||||
cp -R /unpack_axcl/usr/lib/axcl /usr/lib/ && \
|
||||
rm -rf /unpack_axcl /axcl.deb
|
||||
|
||||
|
||||
# Install axcl ffmpeg
|
||||
RUN mkdir -p /usr/lib/ffmpeg/axcl
|
||||
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then \
|
||||
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffmpeg-x64 -O /usr/lib/ffmpeg/axcl/ffmpeg && \
|
||||
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffprobe-x64 -O /usr/lib/ffmpeg/axcl/ffprobe; \
|
||||
else \
|
||||
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffmpeg-aarch64 -O /usr/lib/ffmpeg/axcl/ffmpeg && \
|
||||
wget https://github.com/ivanshi1108/assets/releases/download/v0.16.2/ffprobe-aarch64 -O /usr/lib/ffmpeg/axcl/ffprobe; \
|
||||
fi
|
||||
|
||||
RUN chmod +x /usr/lib/ffmpeg/axcl/ffmpeg /usr/lib/ffmpeg/axcl/ffprobe
|
||||
|
||||
# Set ldconfig path
|
||||
RUN echo "/usr/lib/axcl" > /etc/ld.so.conf.d/ax.conf
|
||||
|
||||
# Set env
|
||||
ENV PATH="$PATH:/usr/bin/axcl"
|
||||
ENV LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/axcl"
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "ldconfig && exec /init"]
|
||||
13
docker/axcl/axcl.hcl
Normal file
13
docker/axcl/axcl.hcl
Normal file
@ -0,0 +1,13 @@
|
||||
target frigate {
|
||||
dockerfile = "docker/main/Dockerfile"
|
||||
platforms = ["linux/amd64", "linux/arm64"]
|
||||
target = "frigate"
|
||||
}
|
||||
|
||||
target axcl {
|
||||
dockerfile = "docker/axcl/Dockerfile"
|
||||
contexts = {
|
||||
frigate = "target:frigate",
|
||||
}
|
||||
platforms = ["linux/amd64", "linux/arm64"]
|
||||
}
|
||||
15
docker/axcl/axcl.mk
Normal file
15
docker/axcl/axcl.mk
Normal file
@ -0,0 +1,15 @@
|
||||
BOARDS += axcl
|
||||
|
||||
local-axcl: version
|
||||
docker buildx bake --file=docker/axcl/axcl.hcl axcl \
|
||||
--set axcl.tags=frigate:latest-axcl \
|
||||
--load
|
||||
|
||||
build-axcl: version
|
||||
docker buildx bake --file=docker/axcl/axcl.hcl axcl \
|
||||
--set axcl.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-axcl
|
||||
|
||||
push-axcl: build-axcl
|
||||
docker buildx bake --file=docker/axcl/axcl.hcl axcl \
|
||||
--set axcl.tags=$(IMAGE_REPO):${GITHUB_REF_NAME}-$(COMMIT_HASH)-axcl \
|
||||
--push
|
||||
83
docker/axcl/user_installation.sh
Executable file
83
docker/axcl/user_installation.sh
Executable file
@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Update package list and install dependencies
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential cmake git wget pciutils kmod udev
|
||||
|
||||
# Check if gcc-12 is needed
|
||||
current_gcc_version=$(gcc --version | head -n1 | awk '{print $NF}')
|
||||
gcc_major_version=$(echo $current_gcc_version | cut -d'.' -f1)
|
||||
|
||||
if [[ $gcc_major_version -lt 12 ]]; then
|
||||
echo "Current GCC version ($current_gcc_version) is lower than 12, installing gcc-12..."
|
||||
sudo apt-get install -y gcc-12
|
||||
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-12 12
|
||||
echo "GCC-12 installed and set as default"
|
||||
else
|
||||
echo "Current GCC version ($current_gcc_version) is sufficient, skipping GCC installation"
|
||||
fi
|
||||
|
||||
# Determine architecture
|
||||
arch=$(uname -m)
|
||||
download_url=""
|
||||
|
||||
if [[ $arch == "x86_64" ]]; then
|
||||
download_url="https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_x86_64_V3.6.5_20250908154509_NO4973.deb"
|
||||
deb_file="axcl_host_x86_64_V3.6.5_20250908154509_NO4973.deb"
|
||||
elif [[ $arch == "aarch64" ]]; then
|
||||
download_url="https://github.com/ivanshi1108/assets/releases/download/v0.16.2/axcl_host_aarch64_V3.6.5_20250908154509_NO4973.deb"
|
||||
deb_file="axcl_host_aarch64_V3.6.5_20250908154509_NO4973.deb"
|
||||
else
|
||||
echo "Unsupported architecture: $arch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download AXCL driver
|
||||
echo "Downloading AXCL driver for $arch..."
|
||||
wget "$download_url" -O "$deb_file"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to download AXCL driver"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install AXCL driver
|
||||
echo "Installing AXCL driver..."
|
||||
sudo dpkg -i "$deb_file"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Failed to install AXCL driver, attempting to fix dependencies..."
|
||||
sudo apt-get install -f -y
|
||||
sudo dpkg -i "$deb_file"
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "AXCL driver installation failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Update environment
|
||||
echo "Updating environment..."
|
||||
source /etc/profile
|
||||
|
||||
# Verify installation
|
||||
echo "Verifying AXCL installation..."
|
||||
if command -v axcl-smi &> /dev/null; then
|
||||
echo "AXCL driver detected, checking AI accelerator status..."
|
||||
|
||||
axcl_output=$(axcl-smi 2>&1)
|
||||
axcl_exit_code=$?
|
||||
|
||||
echo "$axcl_output"
|
||||
|
||||
if [ $axcl_exit_code -eq 0 ]; then
|
||||
echo "AXCL driver installation completed successfully!"
|
||||
else
|
||||
echo "AXCL driver installed but no AI accelerator detected or communication failed."
|
||||
echo "Please check if the AI accelerator is properly connected and powered on."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "axcl-smi command not found. AXCL driver installation may have failed."
|
||||
exit 1
|
||||
fi
|
||||
@ -15,7 +15,7 @@ ARG AMDGPU
|
||||
|
||||
RUN apt update -qq && \
|
||||
apt install -y wget gpg && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1/ubuntu/jammy/amdgpu-install_7.1.70100-1_all.deb && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1.1/ubuntu/jammy/amdgpu-install_7.1.1.70101-1_all.deb && \
|
||||
apt install -y ./rocm.deb && \
|
||||
apt update && \
|
||||
apt install -qq -y rocm
|
||||
|
||||
@ -2,7 +2,7 @@ variable "AMDGPU" {
|
||||
default = "gfx900"
|
||||
}
|
||||
variable "ROCM" {
|
||||
default = "7.1.0"
|
||||
default = "7.1.1"
|
||||
}
|
||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||
default = ""
|
||||
|
||||
@ -157,3 +157,19 @@ Only one `speech` event may be transcribed at a time. Frigate does not automatic
|
||||
:::
|
||||
|
||||
Recorded `speech` events will always use a `whisper` model, regardless of the `model_size` config setting. Without a supported Nvidia GPU, generating transcriptions for longer `speech` events may take a fair amount of time, so be patient.
|
||||
|
||||
#### FAQ
|
||||
|
||||
1. Why doesn't Frigate automatically transcribe all `speech` events?
|
||||
|
||||
Frigate does not implement a queue mechanism for speech transcription, and adding one is not trivial. A proper queue would need backpressure, prioritization, memory/disk buffering, retry logic, crash recovery, and safeguards to prevent unbounded growth when events outpace processing. That’s a significant amount of complexity for a feature that, in most real-world environments, would mostly just churn through low-value noise.
|
||||
|
||||
Because transcription is **serialized (one event at a time)** and speech events can be generated far faster than they can be processed, an auto-transcribe toggle would very quickly create an ever-growing backlog and degrade core functionality. For the amount of engineering and risk involved, it adds **very little practical value** for the majority of deployments, which are often on low-powered, edge hardware.
|
||||
|
||||
If you hear speech that’s actually important and worth saving/indexing for the future, **just press the transcribe button in Explore** on that specific `speech` event - that keeps things explicit, reliable, and under your control.
|
||||
|
||||
2. Why don't you save live transcription text and use that for `speech` events?
|
||||
|
||||
There’s no guarantee that a `speech` event is even created from the exact audio that went through the transcription model. Live transcription and `speech` event creation are **separate, asynchronous processes**. Even when both are correctly configured, trying to align the **precise start and end time of a speech event** with whatever audio the model happened to be processing at that moment is unreliable.
|
||||
|
||||
Automatically persisting that data would often result in **misaligned, partial, or irrelevant transcripts**, while still incurring all of the CPU, storage, and privacy costs of transcription. That’s why Frigate treats transcription as an **explicit, user-initiated action** rather than an automatic side-effect of every `speech` event.
|
||||
|
||||
@ -49,6 +49,11 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs.
|
||||
|
||||
**AXERA** <CommunityBadge />
|
||||
|
||||
- [AXEngine](#axera): axmodels can run on AXERA AI acceleration.
|
||||
|
||||
|
||||
**For Testing**
|
||||
|
||||
- [CPU Detector (not recommended for actual use](#cpu-detector-not-recommended): Use a CPU to run tflite model, this is not recommended and in most cases OpenVINO can be used in CPU mode with better results.
|
||||
@ -1438,6 +1443,41 @@ model:
|
||||
input_pixel_format: rgb/bgr # look at the model.json to figure out which to put here
|
||||
```
|
||||
|
||||
## AXERA
|
||||
|
||||
Hardware accelerated object detection is supported on the following SoCs:
|
||||
|
||||
- AX650N
|
||||
- AX8850N
|
||||
|
||||
This implementation uses the [AXera Pulsar2 Toolchain](https://huggingface.co/AXERA-TECH/Pulsar2).
|
||||
|
||||
See the [installation docs](../frigate/installation.md#axera) for information on configuring the AXEngine hardware.
|
||||
|
||||
### Configuration
|
||||
|
||||
When configuring the AXEngine detector, you have to specify the model name.
|
||||
|
||||
#### yolov9
|
||||
|
||||
A yolov9 model is provided in the container at /axmodels and is used by this detector type by default.
|
||||
|
||||
Use the model configuration shown below when using the axengine detector with the default axmodel:
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
axengine:
|
||||
type: axengine
|
||||
|
||||
model:
|
||||
path: frigate-yolov9-tiny
|
||||
model_type: yolo-generic
|
||||
width: 320
|
||||
height: 320
|
||||
tensor_format: bgr
|
||||
labelmap_path: /labelmap/coco-80.txt
|
||||
```
|
||||
|
||||
# Models
|
||||
|
||||
Some model types are not included in Frigate by default.
|
||||
|
||||
@ -104,6 +104,10 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
- [Synaptics](#synaptics): synap models can run on Synaptics devices(e.g astra machina) with included NPUs to provide efficient object detection.
|
||||
|
||||
**AXERA** <CommunityBadge />
|
||||
|
||||
- [AXEngine](#axera): axera models can run on AXERA NPUs via AXEngine, delivering highly efficient object detection.
|
||||
|
||||
:::
|
||||
|
||||
### Hailo-8
|
||||
@ -287,6 +291,14 @@ The inference time of a rk3588 with all 3 cores enabled is typically 25-30 ms fo
|
||||
| ssd mobilenet | ~ 25 ms |
|
||||
| yolov5m | ~ 118 ms |
|
||||
|
||||
### AXERA
|
||||
|
||||
- **AXEngine** Default model is **yolov9**
|
||||
|
||||
| Name | AXERA AX650N/AX8850N Inference Time |
|
||||
| ---------------- | ----------------------------------- |
|
||||
| yolov9-tiny | ~ 4 ms |
|
||||
|
||||
## 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.
|
||||
@ -307,4 +319,4 @@ Basically - When you increase the resolution and/or the frame rate of the stream
|
||||
|
||||
YES! The Coral does not help with decoding video streams.
|
||||
|
||||
Decompressing video streams takes a significant amount of CPU power. Video compression uses key frames (also known as I-frames) to send a full frame in the video stream. The following frames only include the difference from the key frame, and the CPU has to compile each frame by merging the differences with the key frame. [More detailed explanation](https://support.video.ibm.com/hc/en-us/articles/18106203580316-Keyframes-InterFrame-Video-Compression). Higher resolutions and frame rates mean more processing power is needed to decode the video stream, so try and set them on the camera to avoid unnecessary decoding work.
|
||||
Decompressing video streams takes a significant amount of CPU power. Video compression uses key frames (also known as I-frames) to send a full frame in the video stream. The following frames only include the difference from the key frame, and the CPU has to compile each frame by merging the differences with the key frame. [More detailed explanation](https://support.video.ibm.com/hc/en-us/articles/18106203580316-Keyframes-InterFrame-Video-Compression). Higher resolutions and frame rates mean more processing power is needed to decode the video stream, so try and set them on the camera to avoid unnecessary decoding work.
|
||||
@ -287,6 +287,42 @@ or add these options to your `docker run` command:
|
||||
|
||||
Next, you should configure [hardware object detection](/configuration/object_detectors#synaptics) and [hardware video processing](/configuration/hardware_acceleration_video#synaptics).
|
||||
|
||||
### AXERA
|
||||
|
||||
<details>
|
||||
<summary>AXERA accelerators</summary>
|
||||
AXERA accelerators are available in an M.2 form factor, compatible with both Raspberry Pi and Orange Pi. This form factor has also been successfully tested on x86 platforms, making it a versatile choice for various computing environments.
|
||||
|
||||
#### Installation
|
||||
|
||||
Using AXERA accelerators requires the installation of the AXCL driver. We provide a convenient Linux script to complete this installation.
|
||||
|
||||
Follow these steps for installation:
|
||||
|
||||
1. Copy or download [this script](https://github.com/ivanshi1108/assets/releases/download/v0.16.2/user_installation.sh).
|
||||
2. Ensure it has execution permissions with `sudo chmod +x user_installation.sh`
|
||||
3. Run the script with `./user_installation.sh`
|
||||
|
||||
#### Setup
|
||||
|
||||
To set up Frigate, follow the default installation instructions, for example: `ghcr.io/blakeblackshear/frigate:stable`
|
||||
|
||||
Next, grant Docker permissions to access your hardware by adding the following lines to your `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
devices:
|
||||
- /dev/axcl_host
|
||||
- /dev/ax_mmb_dev
|
||||
- /dev/msg_userdev
|
||||
```
|
||||
|
||||
If you are using `docker run`, add this option to your command `--device /dev/axcl_host --device /dev/ax_mmb_dev --device /dev/msg_userdev`
|
||||
|
||||
#### Configuration
|
||||
|
||||
Finally, configure [hardware object detection](/configuration/object_detectors#axera) to complete the setup.
|
||||
</details>
|
||||
|
||||
## Docker
|
||||
|
||||
Running through Docker with Docker Compose is the recommended install method.
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
.alert {
|
||||
padding: 12px;
|
||||
background: #fff8e6;
|
||||
border-bottom: 1px solid #ffd166;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.alert a {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
}
|
||||
padding: 12px;
|
||||
background: #fff8e6;
|
||||
border-bottom: 1px solid #ffd166;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .alert {
|
||||
background: #3b2f0b;
|
||||
border-bottom: 1px solid #665c22;
|
||||
}
|
||||
|
||||
.alert a {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
@ -99,6 +99,42 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
if self.inference_speed:
|
||||
self.inference_speed.update(duration)
|
||||
|
||||
def _should_save_image(
|
||||
self, camera: str, detected_state: str, score: float = 1.0
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if we should save the image for training.
|
||||
Save when:
|
||||
- State is changing or being verified (regardless of score)
|
||||
- Score is less than 100% (even if state matches, useful for training)
|
||||
Don't save when:
|
||||
- State is stable (matches current_state) AND score is 100%
|
||||
"""
|
||||
if camera not in self.state_history:
|
||||
# First detection for this camera, save it
|
||||
return True
|
||||
|
||||
verification = self.state_history[camera]
|
||||
current_state = verification.get("current_state")
|
||||
pending_state = verification.get("pending_state")
|
||||
|
||||
# Save if there's a pending state change being verified
|
||||
if pending_state is not None:
|
||||
return True
|
||||
|
||||
# Save if the detected state differs from the current verified state
|
||||
# (state is changing)
|
||||
if current_state is not None and detected_state != current_state:
|
||||
return True
|
||||
|
||||
# If score is less than 100%, save even if state matches
|
||||
# (useful for training to improve confidence)
|
||||
if score < 1.0:
|
||||
return True
|
||||
|
||||
# Don't save if state is stable (detected_state == current_state) AND score is 100%
|
||||
return False
|
||||
|
||||
def verify_state_change(self, camera: str, detected_state: str) -> str | None:
|
||||
"""
|
||||
Verify state change requires 3 consecutive identical states before publishing.
|
||||
@ -212,14 +248,16 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
return
|
||||
|
||||
if self.interpreter is None:
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
"none-none",
|
||||
now,
|
||||
"unknown",
|
||||
0.0,
|
||||
)
|
||||
# When interpreter is None, always save (score is 0.0, which is < 1.0)
|
||||
if self._should_save_image(camera, "unknown", 0.0):
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
"none-none",
|
||||
now,
|
||||
"unknown",
|
||||
0.0,
|
||||
)
|
||||
return
|
||||
|
||||
input = np.expand_dims(resized_frame, axis=0)
|
||||
@ -236,14 +274,17 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
score = round(probs[best_id], 2)
|
||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
"none-none",
|
||||
now,
|
||||
self.labelmap[best_id],
|
||||
score,
|
||||
)
|
||||
detected_state = self.labelmap[best_id]
|
||||
|
||||
if self._should_save_image(camera, detected_state, score):
|
||||
write_classification_attempt(
|
||||
self.train_dir,
|
||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||
"none-none",
|
||||
now,
|
||||
detected_state,
|
||||
score,
|
||||
)
|
||||
|
||||
if score < self.model_config.threshold:
|
||||
logger.debug(
|
||||
@ -251,7 +292,6 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
||||
)
|
||||
return
|
||||
|
||||
detected_state = self.labelmap[best_id]
|
||||
verified_state = self.verify_state_change(camera, detected_state)
|
||||
|
||||
if verified_state is not None:
|
||||
|
||||
86
frigate/detectors/plugins/axengine.py
Normal file
86
frigate/detectors/plugins/axengine.py
Normal file
@ -0,0 +1,86 @@
|
||||
import logging
|
||||
import os.path
|
||||
import re
|
||||
import urllib.request
|
||||
from typing import Literal
|
||||
|
||||
import axengine as axe
|
||||
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.detectors.detection_api import DetectionApi
|
||||
from frigate.detectors.detector_config import BaseDetectorConfig, ModelTypeEnum
|
||||
from frigate.util.model import post_process_yolo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DETECTOR_KEY = "axengine"
|
||||
|
||||
supported_models = {
|
||||
ModelTypeEnum.yologeneric: "frigate-yolov9-.*$",
|
||||
}
|
||||
|
||||
model_cache_dir = os.path.join(MODEL_CACHE_DIR, "axengine_cache/")
|
||||
|
||||
|
||||
class AxengineDetectorConfig(BaseDetectorConfig):
|
||||
type: Literal[DETECTOR_KEY]
|
||||
|
||||
|
||||
class Axengine(DetectionApi):
|
||||
type_key = DETECTOR_KEY
|
||||
|
||||
def __init__(self, config: AxengineDetectorConfig):
|
||||
logger.info("__init__ axengine")
|
||||
super().__init__(config)
|
||||
self.height = config.model.height
|
||||
self.width = config.model.width
|
||||
model_path = config.model.path or "frigate-yolov9-tiny"
|
||||
model_props = self.parse_model_input(model_path)
|
||||
self.session = axe.InferenceSession(model_props["path"])
|
||||
|
||||
def __del__(self):
|
||||
pass
|
||||
|
||||
def parse_model_input(self, model_path):
|
||||
model_props = {}
|
||||
model_props["preset"] = True
|
||||
|
||||
model_matched = False
|
||||
|
||||
for model_type, pattern in supported_models.items():
|
||||
if re.match(pattern, model_path):
|
||||
model_matched = True
|
||||
model_props["model_type"] = model_type
|
||||
|
||||
if model_matched:
|
||||
model_props["filename"] = model_path + ".axmodel"
|
||||
model_props["path"] = model_cache_dir + model_props["filename"]
|
||||
|
||||
if not os.path.isfile(model_props["path"]):
|
||||
self.download_model(model_props["filename"])
|
||||
else:
|
||||
supported_models_str = ", ".join(model[1:-1] for model in supported_models)
|
||||
raise Exception(
|
||||
f"Model {model_path} is unsupported. Provide your own model or choose one of the following: {supported_models_str}"
|
||||
)
|
||||
return model_props
|
||||
|
||||
def download_model(self, filename):
|
||||
if not os.path.isdir(model_cache_dir):
|
||||
os.mkdir(model_cache_dir)
|
||||
|
||||
GITHUB_ENDPOINT = os.environ.get("GITHUB_ENDPOINT", "https://github.com")
|
||||
urllib.request.urlretrieve(
|
||||
f"{GITHUB_ENDPOINT}/ivanshi1108/assets/releases/download/v0.16.2/{filename}",
|
||||
model_cache_dir + filename,
|
||||
)
|
||||
|
||||
def detect_raw(self, tensor_input):
|
||||
results = None
|
||||
results = self.session.run(None, {"images": tensor_input})
|
||||
if self.detector_config.model.model_type == ModelTypeEnum.yologeneric:
|
||||
return post_process_yolo(results, self.width, self.height)
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Model type "{self.detector_config.model.model_type}" is currently not supported.'
|
||||
)
|
||||
@ -190,7 +190,11 @@ class OnvifController:
|
||||
ptz: ONVIFService = await onvif.create_ptz_service()
|
||||
self.cams[camera_name]["ptz"] = ptz
|
||||
|
||||
imaging: ONVIFService = await onvif.create_imaging_service()
|
||||
try:
|
||||
imaging: ONVIFService = await onvif.create_imaging_service()
|
||||
except (Fault, ONVIFError, TransportError, Exception) as e:
|
||||
logger.debug(f"Imaging service not supported for {camera_name}: {e}")
|
||||
imaging = None
|
||||
self.cams[camera_name]["imaging"] = imaging
|
||||
try:
|
||||
video_sources = await media.GetVideoSources()
|
||||
@ -381,7 +385,10 @@ class OnvifController:
|
||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
|
||||
)
|
||||
|
||||
if self.cams[camera_name]["video_source_token"] is not None:
|
||||
if (
|
||||
self.cams[camera_name]["video_source_token"] is not None
|
||||
and imaging is not None
|
||||
):
|
||||
try:
|
||||
imaging_capabilities = await imaging.GetImagingSettings(
|
||||
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
|
||||
@ -421,6 +428,7 @@ class OnvifController:
|
||||
if (
|
||||
"focus" in self.cams[camera_name]["features"]
|
||||
and self.cams[camera_name]["video_source_token"]
|
||||
and self.cams[camera_name]["imaging"] is not None
|
||||
):
|
||||
try:
|
||||
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
|
||||
@ -648,6 +656,7 @@ class OnvifController:
|
||||
if (
|
||||
"focus" not in self.cams[camera_name]["features"]
|
||||
or not self.cams[camera_name]["video_source_token"]
|
||||
or self.cams[camera_name]["imaging"] is None
|
||||
):
|
||||
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
|
||||
return
|
||||
|
||||
@ -124,45 +124,50 @@ def capture_frames(
|
||||
config_subscriber.check_for_updates()
|
||||
return config.enabled
|
||||
|
||||
while not stop_event.is_set():
|
||||
if not get_enabled_state():
|
||||
logger.debug(f"Stopping capture thread for disabled {config.name}")
|
||||
break
|
||||
|
||||
fps.value = frame_rate.eps()
|
||||
skipped_fps.value = skipped_eps.eps()
|
||||
current_frame.value = datetime.now().timestamp()
|
||||
frame_name = f"{config.name}_frame{frame_index}"
|
||||
frame_buffer = frame_manager.write(frame_name)
|
||||
try:
|
||||
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
|
||||
except Exception:
|
||||
# shutdown has been initiated
|
||||
if stop_event.is_set():
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
if not get_enabled_state():
|
||||
logger.debug(f"Stopping capture thread for disabled {config.name}")
|
||||
break
|
||||
|
||||
logger.error(f"{config.name}: Unable to read frames from ffmpeg process.")
|
||||
fps.value = frame_rate.eps()
|
||||
skipped_fps.value = skipped_eps.eps()
|
||||
current_frame.value = datetime.now().timestamp()
|
||||
frame_name = f"{config.name}_frame{frame_index}"
|
||||
frame_buffer = frame_manager.write(frame_name)
|
||||
try:
|
||||
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
|
||||
except Exception:
|
||||
# shutdown has been initiated
|
||||
if stop_event.is_set():
|
||||
break
|
||||
|
||||
if ffmpeg_process.poll() is not None:
|
||||
logger.error(
|
||||
f"{config.name}: ffmpeg process is not running. exiting capture thread..."
|
||||
f"{config.name}: Unable to read frames from ffmpeg process."
|
||||
)
|
||||
break
|
||||
|
||||
continue
|
||||
if ffmpeg_process.poll() is not None:
|
||||
logger.error(
|
||||
f"{config.name}: ffmpeg process is not running. exiting capture thread..."
|
||||
)
|
||||
break
|
||||
|
||||
frame_rate.update()
|
||||
continue
|
||||
|
||||
# don't lock the queue to check, just try since it should rarely be full
|
||||
try:
|
||||
# add to the queue
|
||||
frame_queue.put((frame_name, current_frame.value), False)
|
||||
frame_manager.close(frame_name)
|
||||
except queue.Full:
|
||||
# if the queue is full, skip this frame
|
||||
skipped_eps.update()
|
||||
frame_rate.update()
|
||||
|
||||
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
|
||||
# don't lock the queue to check, just try since it should rarely be full
|
||||
try:
|
||||
# add to the queue
|
||||
frame_queue.put((frame_name, current_frame.value), False)
|
||||
frame_manager.close(frame_name)
|
||||
except queue.Full:
|
||||
# if the queue is full, skip this frame
|
||||
skipped_eps.update()
|
||||
|
||||
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
|
||||
finally:
|
||||
config_subscriber.stop()
|
||||
|
||||
|
||||
class CameraWatchdog(threading.Thread):
|
||||
@ -234,6 +239,16 @@ class CameraWatchdog(threading.Thread):
|
||||
else:
|
||||
self.ffmpeg_detect_process.wait()
|
||||
|
||||
# Wait for old capture thread to fully exit before starting a new one
|
||||
if self.capture_thread is not None and self.capture_thread.is_alive():
|
||||
self.logger.info("Waiting for capture thread to exit...")
|
||||
self.capture_thread.join(timeout=5)
|
||||
|
||||
if self.capture_thread.is_alive():
|
||||
self.logger.warning(
|
||||
f"Capture thread for {self.config.name} did not exit in time"
|
||||
)
|
||||
|
||||
self.logger.error(
|
||||
"The following ffmpeg logs include the last 100 lines prior to exit."
|
||||
)
|
||||
|
||||
@ -5,7 +5,7 @@ import { Button } from "../ui/button";
|
||||
import { LuSettings } from "react-icons/lu";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -24,7 +24,7 @@ export default function DebugCameraImage({
|
||||
}: DebugCameraImageProps) {
|
||||
const { t } = useTranslation(["components/camera"]);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [options, setOptions] = usePersistence<Options>(
|
||||
const [options, setOptions] = useUserPersistence<Options>(
|
||||
`${cameraConfig?.name}-feed`,
|
||||
emptyObject,
|
||||
);
|
||||
|
||||
@ -13,7 +13,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { VideoPreview } from "../preview/ScrubbablePreview";
|
||||
import { useApiHost } from "@/api";
|
||||
import { isDesktop, isSafari } from "react-device-detect";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { Button } from "../ui/button";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
@ -112,7 +112,7 @@ export function AnimatedEventCard({
|
||||
|
||||
// image behavior
|
||||
|
||||
const [alertVideos, _, alertVideosLoaded] = usePersistence(
|
||||
const [alertVideos, _, alertVideosLoaded] = useUserPersistence(
|
||||
"alertVideos",
|
||||
true,
|
||||
);
|
||||
|
||||
@ -37,7 +37,7 @@ import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuPlus, LuX } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { z } from "zod";
|
||||
|
||||
type ClassificationModelEditDialogProps = {
|
||||
@ -240,15 +240,61 @@ export default function ClassificationModelEditDialog({
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
// State model - update classes
|
||||
// Note: For state models, updating classes requires renaming categories
|
||||
// which is handled through the dataset API, not the config API
|
||||
// We'll need to implement this by calling the rename endpoint for each class
|
||||
// For now, we just show a message that this requires retraining
|
||||
const stateData = data as StateFormData;
|
||||
const newClasses = stateData.classes.filter(
|
||||
(c) => c.trim().length > 0,
|
||||
);
|
||||
const oldClasses = dataset?.categories
|
||||
? Object.keys(dataset.categories).filter((key) => key !== "none")
|
||||
: [];
|
||||
|
||||
toast.info(t("edit.stateClassesInfo"), {
|
||||
position: "top-center",
|
||||
});
|
||||
const renameMap = new Map<string, string>();
|
||||
const maxLength = Math.max(oldClasses.length, newClasses.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const oldClass = oldClasses[i];
|
||||
const newClass = newClasses[i];
|
||||
|
||||
if (oldClass && newClass && oldClass !== newClass) {
|
||||
renameMap.set(oldClass, newClass);
|
||||
}
|
||||
}
|
||||
|
||||
const renamePromises = Array.from(renameMap.entries()).map(
|
||||
async ([oldName, newName]) => {
|
||||
try {
|
||||
await axios.put(
|
||||
`/classification/${model.name}/dataset/${oldName}/rename`,
|
||||
{
|
||||
new_category: newName,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const error = err as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
};
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
throw new Error(
|
||||
`Failed to rename ${oldName} to ${newName}: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (renamePromises.length > 0) {
|
||||
await Promise.all(renamePromises);
|
||||
await mutate(`classification/${model.name}/dataset`);
|
||||
toast.success(t("toast.success.updatedModel"), {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.info(t("edit.stateClassesInfo"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
@ -256,8 +302,10 @@ export default function ClassificationModelEditDialog({
|
||||
} catch (err) {
|
||||
const error = err as {
|
||||
response?: { data?: { message?: string; detail?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const errorMessage =
|
||||
error.message ||
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
@ -268,7 +316,7 @@ export default function ClassificationModelEditDialog({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[isObjectModel, model, t, onSuccess, onClose],
|
||||
[isObjectModel, model, dataset, t, onSuccess, onClose],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
import { MdHome } from "react-icons/md";
|
||||
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
@ -57,7 +56,7 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as LuIcons from "react-icons/lu";
|
||||
@ -79,6 +78,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
|
||||
type CameraGroupSelectorProps = {
|
||||
className?: string;
|
||||
@ -109,9 +109,9 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
[timeoutId],
|
||||
);
|
||||
|
||||
// groups
|
||||
// groups - use user-namespaced key for persistence to avoid cross-user conflicts
|
||||
|
||||
const [group, setGroup, , deleteGroup] = usePersistedOverlayState(
|
||||
const [group, setGroup, , deleteGroup] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
);
|
||||
@ -276,7 +276,7 @@ function NewGroupDialog({
|
||||
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [, , , deleteGridLayout] = usePersistence(
|
||||
const [, , , deleteGridLayout] = useUserPersistence(
|
||||
`${activeGroup}-draggable-layout`,
|
||||
);
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { SaveSearchDialog } from "./SaveSearchDialog";
|
||||
import { DeleteSearchDialog } from "./DeleteSearchDialog";
|
||||
import {
|
||||
@ -128,9 +128,8 @@ export default function InputWithTags({
|
||||
|
||||
// TODO: search history from browser storage
|
||||
|
||||
const [searchHistory, setSearchHistory, searchHistoryLoaded] = usePersistence<
|
||||
SavedSearchQuery[]
|
||||
>("frigate-search-history");
|
||||
const [searchHistory, setSearchHistory, searchHistoryLoaded] =
|
||||
useUserPersistence<SavedSearchQuery[]>("frigate-search-history");
|
||||
|
||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
@ -48,6 +48,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
import { LiveStreamMetadata } from "@/types/live";
|
||||
|
||||
type LiveContextMenuProps = {
|
||||
className?: string;
|
||||
@ -68,6 +69,7 @@ type LiveContextMenuProps = {
|
||||
resetPreferredLiveMode: () => void;
|
||||
config?: FrigateConfig;
|
||||
children?: ReactNode;
|
||||
streamMetadata?: { [key: string]: LiveStreamMetadata };
|
||||
};
|
||||
export default function LiveContextMenu({
|
||||
className,
|
||||
@ -88,6 +90,7 @@ export default function LiveContextMenu({
|
||||
resetPreferredLiveMode,
|
||||
config,
|
||||
children,
|
||||
streamMetadata,
|
||||
}: LiveContextMenuProps) {
|
||||
const { t } = useTranslation("views/live");
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
@ -558,6 +561,7 @@ export default function LiveContextMenu({
|
||||
setGroupStreamingSettings={setGroupStreamingSettings}
|
||||
setIsDialogOpen={setShowSettings}
|
||||
onSave={onSave}
|
||||
streamMetadata={streamMetadata}
|
||||
/>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,7 @@ import { FaCircle } from "react-icons/fa";
|
||||
import { getUTCOffset } from "@/utils/dateUtil";
|
||||
import { type DayButtonProps, TZDate } from "react-day-picker";
|
||||
import { LAST_24_HOURS_KEY } from "@/types/filter";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
@ -27,7 +27,7 @@ export default function ReviewActivityCalendar({
|
||||
}: ReviewActivityCalendarProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const timezone = useTimezone(config);
|
||||
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
|
||||
const disabledDates = useMemo(() => {
|
||||
const tomorrow = new Date();
|
||||
@ -176,7 +176,7 @@ export function TimezoneAwareCalendar({
|
||||
selectedDay,
|
||||
onSelect,
|
||||
}: TimezoneAwareCalendarProps) {
|
||||
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
|
||||
const timezoneOffset = useMemo(
|
||||
() =>
|
||||
|
||||
@ -15,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -210,9 +210,9 @@ export default function HlsVideoPlayer({
|
||||
|
||||
const [tallCamera, setTallCamera] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(true);
|
||||
const [muted, setMuted] = usePersistence("hlsPlayerMuted", true);
|
||||
const [muted, setMuted] = useUserPersistence("hlsPlayerMuted", true);
|
||||
const [volume, setVolume] = useOverlayState("playerVolume", 1.0);
|
||||
const [defaultPlaybackRate] = usePersistence("playbackRate", 1);
|
||||
const [defaultPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||
const [playbackRate, setPlaybackRate] = useOverlayState(
|
||||
"playbackRate",
|
||||
defaultPlaybackRate ?? 1,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
@ -72,7 +72,10 @@ function MSEPlayer({
|
||||
const [errorCount, setErrorCount] = useState<number>(0);
|
||||
const totalBytesLoaded = useRef(0);
|
||||
|
||||
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3);
|
||||
const [fallbackTimeout] = useUserPersistence<number>(
|
||||
"liveFallbackTimeout",
|
||||
3,
|
||||
);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
@ -38,6 +38,7 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
type CameraStreamingDialogProps = {
|
||||
camera: string;
|
||||
groupStreamingSettings: GroupStreamingSettings;
|
||||
streamMetadata?: { [key: string]: LiveStreamMetadata };
|
||||
setGroupStreamingSettings: React.Dispatch<
|
||||
React.SetStateAction<GroupStreamingSettings>
|
||||
>;
|
||||
@ -48,6 +49,7 @@ type CameraStreamingDialogProps = {
|
||||
export function CameraStreamingDialog({
|
||||
camera,
|
||||
groupStreamingSettings,
|
||||
streamMetadata,
|
||||
setGroupStreamingSettings,
|
||||
setIsDialogOpen,
|
||||
onSave,
|
||||
@ -76,12 +78,7 @@ export function CameraStreamingDialog({
|
||||
[config, streamName],
|
||||
);
|
||||
|
||||
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
|
||||
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
const cameraMetadata = streamName ? streamMetadata?.[streamName] : undefined;
|
||||
|
||||
const supportsAudioOutput = useMemo(() => {
|
||||
if (!cameraMetadata) {
|
||||
|
||||
@ -24,7 +24,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||
@ -58,7 +58,7 @@ export default function DetailStream({
|
||||
const effectiveTime = currentTime - annotationOffset / 1000;
|
||||
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
||||
const [controlsExpanded, setControlsExpanded] = useState(false);
|
||||
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
|
||||
const [alwaysExpandActive, setAlwaysExpandActive] = useUserPersistence(
|
||||
"detailStreamActiveExpanded",
|
||||
true,
|
||||
);
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
useContext,
|
||||
} from "react";
|
||||
import { AllGroupsStreamingSettings } from "@/types/frigateConfig";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
|
||||
type StreamingSettingsContextType = {
|
||||
allGroupsStreamingSettings: AllGroupsStreamingSettings;
|
||||
@ -29,7 +29,7 @@ export function StreamingSettingsProvider({
|
||||
persistedGroupStreamingSettings,
|
||||
setPersistedGroupStreamingSettings,
|
||||
isPersistedStreamingSettingsLoaded,
|
||||
] = usePersistence<AllGroupsStreamingSettings>("streaming-settings");
|
||||
] = useUserPersistence<AllGroupsStreamingSettings>("streaming-settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (isPersistedStreamingSettingsLoaded) {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
|
||||
import { LivePlayerMode } from "@/types/live";
|
||||
import useDeferredStreamMetadata from "./use-deferred-stream-metadata";
|
||||
|
||||
export default function useCameraLiveMode(
|
||||
cameras: CameraConfig[],
|
||||
@ -11,9 +11,9 @@ export default function useCameraLiveMode(
|
||||
) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// Get comma-separated list of restreamed stream names for SWR key
|
||||
const restreamedStreamsKey = useMemo(() => {
|
||||
if (!cameras || !config) return null;
|
||||
// Compute which streams need metadata (restreamed streams only)
|
||||
const restreamedStreamNames = useMemo(() => {
|
||||
if (!cameras || !config) return [];
|
||||
|
||||
const streamNames = new Set<string>();
|
||||
cameras.forEach((camera) => {
|
||||
@ -32,56 +32,13 @@ export default function useCameraLiveMode(
|
||||
}
|
||||
});
|
||||
|
||||
return streamNames.size > 0
|
||||
? Array.from(streamNames).sort().join(",")
|
||||
: null;
|
||||
return Array.from(streamNames);
|
||||
}, [cameras, config, activeStreams]);
|
||||
|
||||
const streamsFetcher = useCallback(async (key: string) => {
|
||||
const streamNames = key.split(",");
|
||||
|
||||
const metadataPromises = streamNames.map(async (streamName) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}api/go2rtc/streams/${streamName}`,
|
||||
{
|
||||
priority: "low",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return { streamName, data };
|
||||
}
|
||||
return { streamName, data: null };
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to fetch metadata for ${streamName}:`, error);
|
||||
return { streamName, data: null };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(metadataPromises);
|
||||
|
||||
const metadata: { [key: string]: LiveStreamMetadata } = {};
|
||||
results.forEach((result) => {
|
||||
if (result.status === "fulfilled" && result.value.data) {
|
||||
metadata[result.value.streamName] = result.value.data;
|
||||
}
|
||||
});
|
||||
|
||||
return metadata;
|
||||
}, []);
|
||||
|
||||
const { data: allStreamMetadata = {} } = useSWR<{
|
||||
[key: string]: LiveStreamMetadata;
|
||||
}>(restreamedStreamsKey, streamsFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
dedupingInterval: 60000,
|
||||
});
|
||||
// Fetch stream metadata with deferred loading (doesn't block initial render)
|
||||
const streamMetadata = useDeferredStreamMetadata(restreamedStreamNames);
|
||||
|
||||
// Compute live mode states
|
||||
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
||||
[key: string]: LivePlayerMode;
|
||||
}>({});
|
||||
@ -122,10 +79,10 @@ export default function useCameraLiveMode(
|
||||
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
|
||||
}
|
||||
|
||||
// check each stream for audio support
|
||||
// Check each stream for audio support
|
||||
if (isRestreamed) {
|
||||
Object.values(camera.live.streams).forEach((streamName) => {
|
||||
const metadata = allStreamMetadata?.[streamName];
|
||||
const metadata = streamMetadata[streamName];
|
||||
newSupportsAudioOutputStates[streamName] = {
|
||||
supportsAudio: metadata
|
||||
? metadata.producers.find(
|
||||
@ -150,7 +107,7 @@ export default function useCameraLiveMode(
|
||||
setPreferredLiveModes(newPreferredLiveModes);
|
||||
setIsRestreamedStates(newIsRestreamedStates);
|
||||
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
|
||||
}, [cameras, config, windowVisible, allStreamMetadata]);
|
||||
}, [cameras, config, windowVisible, streamMetadata]);
|
||||
|
||||
const resetPreferredLiveMode = useCallback(
|
||||
(cameraName: string) => {
|
||||
@ -180,5 +137,6 @@ export default function useCameraLiveMode(
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
streamMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
90
web/src/hooks/use-deferred-stream-metadata.ts
Normal file
90
web/src/hooks/use-deferred-stream-metadata.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import { LiveStreamMetadata } from "@/types/live";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
const DEFER_DELAY_MS = 2000;
|
||||
|
||||
/**
|
||||
* Hook that fetches go2rtc stream metadata with deferred loading.
|
||||
*
|
||||
* Metadata fetching is delayed to prevent blocking initial page load
|
||||
* and camera image requests.
|
||||
*
|
||||
* @param streamNames - Array of stream names to fetch metadata for
|
||||
* @returns Object containing stream metadata keyed by stream name
|
||||
*/
|
||||
export default function useDeferredStreamMetadata(streamNames: string[]) {
|
||||
const [fetchEnabled, setFetchEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setFetchEnabled(true);
|
||||
}, DEFER_DELAY_MS);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
const swrKey = useMemo(() => {
|
||||
if (!fetchEnabled || streamNames.length === 0) return null;
|
||||
// Use spread to avoid mutating the original array
|
||||
return `deferred-streams:${[...streamNames].sort().join(",")}`;
|
||||
}, [fetchEnabled, streamNames]);
|
||||
|
||||
const fetcher = useCallback(async (key: string) => {
|
||||
// Extract stream names from key (remove prefix)
|
||||
const names = key.replace("deferred-streams:", "").split(",");
|
||||
|
||||
const promises = names.map(async (streamName) => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}api/go2rtc/streams/${streamName}`,
|
||||
{
|
||||
priority: "low",
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return { streamName, data };
|
||||
}
|
||||
return { streamName, data: null };
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to fetch metadata for ${streamName}:`, error);
|
||||
}
|
||||
return { streamName, data: null };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
const metadata: { [key: string]: LiveStreamMetadata } = {};
|
||||
results.forEach((result) => {
|
||||
if (result.status === "fulfilled" && result.value.data) {
|
||||
metadata[result.value.streamName] = result.value.data;
|
||||
}
|
||||
});
|
||||
|
||||
return metadata;
|
||||
}, []);
|
||||
|
||||
const { data: metadata = {} } = useSWR<{
|
||||
[key: string]: LiveStreamMetadata;
|
||||
}>(swrKey, fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
dedupingInterval: 60000,
|
||||
});
|
||||
|
||||
return metadata;
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { usePersistence } from "./use-persistence";
|
||||
import { useUserPersistence } from "./use-user-persistence";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
|
||||
export function useOverlayState<S>(
|
||||
key: string,
|
||||
@ -79,6 +81,60 @@ export function usePersistedOverlayState<S extends string>(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Like usePersistedOverlayState, but namespaces the persistence key by username.
|
||||
* This ensures different users on the same browser don't share state.
|
||||
* Automatically migrates data from legacy (non-namespaced) keys on first use.
|
||||
*/
|
||||
export function useUserPersistedOverlayState<S extends string>(
|
||||
key: string,
|
||||
defaultValue: S | undefined = undefined,
|
||||
): [
|
||||
S | undefined,
|
||||
(value: S | undefined, replace?: boolean) => void,
|
||||
boolean,
|
||||
() => void,
|
||||
] {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const currentLocationState = useMemo(() => location.state, [location]);
|
||||
|
||||
// currently selected value from URL state
|
||||
const overlayStateValue = useMemo<S | undefined>(
|
||||
() => location.state && location.state[key],
|
||||
[location, key],
|
||||
);
|
||||
|
||||
// saved value from previous session (user-namespaced with migration)
|
||||
const [persistedValue, setPersistedValue, loaded, deletePersistedValue] =
|
||||
useUserPersistence<S>(key, overlayStateValue);
|
||||
|
||||
const setOverlayStateValue = useCallback(
|
||||
(value: S | undefined, replace: boolean = false) => {
|
||||
setPersistedValue(value);
|
||||
const newLocationState = { ...currentLocationState };
|
||||
newLocationState[key] = value;
|
||||
navigate(location.pathname, { state: newLocationState, replace });
|
||||
},
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[key, currentLocationState, navigate, setPersistedValue],
|
||||
);
|
||||
|
||||
// Don't return a value until auth has finished loading
|
||||
if (auth.isLoading) {
|
||||
return [undefined, setOverlayStateValue, false, deletePersistedValue];
|
||||
}
|
||||
|
||||
return [
|
||||
overlayStateValue ?? persistedValue ?? defaultValue,
|
||||
setOverlayStateValue,
|
||||
loaded,
|
||||
deletePersistedValue,
|
||||
];
|
||||
}
|
||||
|
||||
export function useHashState<S extends string>(): [
|
||||
S | undefined,
|
||||
(value: S) => void,
|
||||
|
||||
199
web/src/hooks/use-user-persistence.ts
Normal file
199
web/src/hooks/use-user-persistence.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import { useEffect, useState, useCallback, useContext, useRef } from "react";
|
||||
import { get as getData, set as setData, del as delData } from "idb-keyval";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
|
||||
type useUserPersistenceReturn<S> = [
|
||||
value: S | undefined,
|
||||
setValue: (value: S | undefined) => void,
|
||||
loaded: boolean,
|
||||
deleteValue: () => void,
|
||||
];
|
||||
|
||||
// Key used to track which keys have been migrated to prevent re-reading old keys
|
||||
const MIGRATED_KEYS_STORAGE_KEY = "frigate-migrated-user-keys";
|
||||
|
||||
/**
|
||||
* Compute the user-namespaced key for a given base key and username.
|
||||
*/
|
||||
export function getUserNamespacedKey(
|
||||
key: string,
|
||||
username: string | undefined,
|
||||
): string {
|
||||
const isAuthenticated = username && username !== "anonymous";
|
||||
return isAuthenticated ? `${key}:${username}` : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user-namespaced key from storage.
|
||||
* This is useful for clearing user-specific data from settings pages.
|
||||
*/
|
||||
export async function deleteUserNamespacedKey(
|
||||
key: string,
|
||||
username: string | undefined,
|
||||
): Promise<void> {
|
||||
const namespacedKey = getUserNamespacedKey(key, username);
|
||||
await delData(namespacedKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of keys that have already been migrated for a specific user.
|
||||
*/
|
||||
async function getMigratedKeys(username: string): Promise<Set<string>> {
|
||||
const allMigrated =
|
||||
(await getData<Record<string, string[]>>(MIGRATED_KEYS_STORAGE_KEY)) || {};
|
||||
return new Set(allMigrated[username] || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a key as migrated for a specific user.
|
||||
*/
|
||||
async function markKeyAsMigrated(username: string, key: string): Promise<void> {
|
||||
const allMigrated =
|
||||
(await getData<Record<string, string[]>>(MIGRATED_KEYS_STORAGE_KEY)) || {};
|
||||
const userMigrated = new Set(allMigrated[username] || []);
|
||||
userMigrated.add(key);
|
||||
allMigrated[username] = Array.from(userMigrated);
|
||||
await setData(MIGRATED_KEYS_STORAGE_KEY, allMigrated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for user-namespaced persistence with automatic migration from legacy keys.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Namespaces storage keys by username to isolate per-user preferences
|
||||
* 2. Automatically migrates data from legacy (non-namespaced) keys on first use
|
||||
* 3. Tracks migrated keys to prevent re-reading stale data after migration
|
||||
* 4. Waits for auth to load before returning values to prevent race conditions
|
||||
*
|
||||
* @param key - The base key name (will be namespaced with username)
|
||||
* @param defaultValue - Default value if no persisted value exists
|
||||
*/
|
||||
export function useUserPersistence<S>(
|
||||
key: string,
|
||||
defaultValue: S | undefined = undefined,
|
||||
): useUserPersistenceReturn<S> {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [value, setInternalValue] = useState<S | undefined>(defaultValue);
|
||||
const [loaded, setLoaded] = useState<boolean>(false);
|
||||
const migrationAttemptedRef = useRef(false);
|
||||
|
||||
// Compute the user-namespaced key
|
||||
const username = auth?.user?.username;
|
||||
const isAuthenticated =
|
||||
username && username !== "anonymous" && !auth.isLoading;
|
||||
const namespacedKey = isAuthenticated ? `${key}:${username}` : key;
|
||||
|
||||
// Track the key that was used when loading to prevent cross-key writes
|
||||
const loadedKeyRef = useRef<string | null>(null);
|
||||
|
||||
const setValue = useCallback(
|
||||
(newValue: S | undefined) => {
|
||||
// Only allow writes if we've loaded for this key
|
||||
// This prevents stale callbacks from writing to the wrong key
|
||||
if (loadedKeyRef.current !== namespacedKey) {
|
||||
return;
|
||||
}
|
||||
setInternalValue(newValue);
|
||||
async function update() {
|
||||
await setData(namespacedKey, newValue);
|
||||
}
|
||||
update();
|
||||
},
|
||||
[namespacedKey],
|
||||
);
|
||||
|
||||
const deleteValue = useCallback(async () => {
|
||||
if (loadedKeyRef.current !== namespacedKey) {
|
||||
return;
|
||||
}
|
||||
await delData(namespacedKey);
|
||||
setInternalValue(defaultValue);
|
||||
}, [namespacedKey, defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't load until auth is resolved
|
||||
if (auth.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset state when key changes - this prevents stale writes
|
||||
loadedKeyRef.current = null;
|
||||
migrationAttemptedRef.current = false;
|
||||
setLoaded(false);
|
||||
|
||||
async function loadWithMigration() {
|
||||
// For authenticated users, check if we need to migrate from legacy key
|
||||
if (isAuthenticated && username && !migrationAttemptedRef.current) {
|
||||
migrationAttemptedRef.current = true;
|
||||
|
||||
const migratedKeys = await getMigratedKeys(username);
|
||||
|
||||
// Check if we already have data in the namespaced key
|
||||
const existingNamespacedValue = await getData<S>(namespacedKey);
|
||||
|
||||
if (typeof existingNamespacedValue !== "undefined") {
|
||||
// Already have namespaced data, use it
|
||||
setInternalValue(existingNamespacedValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this key has already been migrated (even if value was deleted)
|
||||
if (migratedKeys.has(key)) {
|
||||
// Already migrated, don't read from legacy key
|
||||
setInternalValue(defaultValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to migrate from legacy key
|
||||
const legacyValue = await getData<S>(key);
|
||||
if (typeof legacyValue !== "undefined") {
|
||||
// Migrate: copy to namespaced key, delete legacy key, mark as migrated
|
||||
await setData(namespacedKey, legacyValue);
|
||||
await delData(key);
|
||||
await markKeyAsMigrated(username, key);
|
||||
setInternalValue(legacyValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// No legacy value, just mark as migrated so we don't check again
|
||||
await markKeyAsMigrated(username, key);
|
||||
setInternalValue(defaultValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// For unauthenticated users or after migration check, just load normally
|
||||
const storedValue = await getData<S>(namespacedKey);
|
||||
if (typeof storedValue !== "undefined") {
|
||||
setInternalValue(storedValue);
|
||||
} else {
|
||||
setInternalValue(defaultValue);
|
||||
}
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
}
|
||||
|
||||
loadWithMigration();
|
||||
}, [
|
||||
auth.isLoading,
|
||||
isAuthenticated,
|
||||
username,
|
||||
key,
|
||||
namespacedKey,
|
||||
defaultValue,
|
||||
]);
|
||||
|
||||
// Don't return a value until auth has finished loading
|
||||
if (auth.isLoading) {
|
||||
return [undefined, setValue, false, deleteValue];
|
||||
}
|
||||
|
||||
return [value, setValue, loaded, deleteValue];
|
||||
}
|
||||
@ -3,7 +3,7 @@ import useApiFilter from "@/hooks/use-api-filter";
|
||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import {
|
||||
@ -42,7 +42,10 @@ export default function Events() {
|
||||
"alert",
|
||||
);
|
||||
|
||||
const [showReviewed, setShowReviewed] = usePersistence("showReviewed", false);
|
||||
const [showReviewed, setShowReviewed] = useUserPersistence(
|
||||
"showReviewed",
|
||||
false,
|
||||
);
|
||||
|
||||
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>(
|
||||
"recording",
|
||||
|
||||
@ -7,7 +7,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar";
|
||||
import { useApiFilterArgs } from "@/hooks/use-api-filter";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
|
||||
import { ModelState } from "@/types/ws";
|
||||
@ -47,7 +47,10 @@ export default function Explore() {
|
||||
|
||||
// grid
|
||||
|
||||
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
|
||||
const [columnCount, setColumnCount] = useUserPersistence(
|
||||
"exploreGridColumns",
|
||||
4,
|
||||
);
|
||||
const gridColumns = useMemo(() => {
|
||||
if (isMobileOnly) {
|
||||
return 2;
|
||||
@ -57,7 +60,7 @@ export default function Explore() {
|
||||
|
||||
// default layout
|
||||
|
||||
const [defaultView, setDefaultView, defaultViewLoaded] = usePersistence(
|
||||
const [defaultView, setDefaultView, defaultViewLoaded] = useUserPersistence(
|
||||
"exploreDefaultView",
|
||||
"summary",
|
||||
);
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import {
|
||||
useHashState,
|
||||
usePersistedOverlayState,
|
||||
useSearchEffect,
|
||||
} from "@/hooks/use-overlay-state";
|
||||
import { useHashState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
|
||||
import LiveCameraView from "@/views/live/LiveCameraView";
|
||||
@ -24,7 +21,7 @@ function Live() {
|
||||
// selection
|
||||
|
||||
const [selectedCameraName, setSelectedCameraName] = useHashState();
|
||||
const [cameraGroup, setCameraGroup, loaded, ,] = usePersistedOverlayState(
|
||||
const [cameraGroup, setCameraGroup, loaded] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
BirdseyeConfig,
|
||||
@ -24,6 +24,7 @@ import "react-resizable/css/styles.css";
|
||||
import {
|
||||
AudioState,
|
||||
LivePlayerMode,
|
||||
LiveStreamMetadata,
|
||||
StatsState,
|
||||
VolumeState,
|
||||
} from "@/types/live";
|
||||
@ -39,7 +40,7 @@ import { IoClose } from "react-icons/io5";
|
||||
import { LuLayoutDashboard, LuPencil } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EditGroupDialog } from "@/components/filter/CameraGroupSelector";
|
||||
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { FaCompress, FaExpand } from "react-icons/fa";
|
||||
import {
|
||||
Tooltip,
|
||||
@ -47,7 +48,6 @@ import {
|
||||
TooltipContent,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
||||
import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -65,6 +65,16 @@ type DraggableGridLayoutProps = {
|
||||
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
fullscreen: boolean;
|
||||
toggleFullscreen: () => void;
|
||||
preferredLiveModes: { [key: string]: LivePlayerMode };
|
||||
setPreferredLiveModes: React.Dispatch<
|
||||
React.SetStateAction<{ [key: string]: LivePlayerMode }>
|
||||
>;
|
||||
resetPreferredLiveMode: (cameraName: string) => void;
|
||||
isRestreamedStates: { [key: string]: boolean };
|
||||
supportsAudioOutputStates: {
|
||||
[key: string]: { supportsAudio: boolean; cameraName: string };
|
||||
};
|
||||
streamMetadata: { [key: string]: LiveStreamMetadata };
|
||||
};
|
||||
export default function DraggableGridLayout({
|
||||
cameras,
|
||||
@ -79,6 +89,12 @@ export default function DraggableGridLayout({
|
||||
setIsEditMode,
|
||||
fullscreen,
|
||||
toggleFullscreen,
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
streamMetadata,
|
||||
}: DraggableGridLayoutProps) {
|
||||
const { t } = useTranslation(["views/live"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -86,8 +102,8 @@ export default function DraggableGridLayout({
|
||||
|
||||
// preferred live modes per camera
|
||||
|
||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
|
||||
|
||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||
useStreamingSettings();
|
||||
@ -98,42 +114,18 @@ export default function DraggableGridLayout({
|
||||
}
|
||||
}, [allGroupsStreamingSettings, cameraGroup]);
|
||||
|
||||
const activeStreams = useMemo(() => {
|
||||
const streams: { [cameraName: string]: string } = {};
|
||||
cameras.forEach((camera) => {
|
||||
const availableStreams = camera.live.streams || {};
|
||||
const streamNameFromSettings =
|
||||
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
|
||||
const streamExists =
|
||||
streamNameFromSettings &&
|
||||
Object.values(availableStreams).includes(streamNameFromSettings);
|
||||
|
||||
const streamName = streamExists
|
||||
? streamNameFromSettings
|
||||
: Object.values(availableStreams)[0] || "";
|
||||
|
||||
streams[camera.name] = streamName;
|
||||
});
|
||||
return streams;
|
||||
}, [cameras, currentGroupStreamingSettings]);
|
||||
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
|
||||
|
||||
// grid layout
|
||||
|
||||
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
||||
|
||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence<
|
||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] = useUserPersistence<
|
||||
Layout[]
|
||||
>(`${cameraGroup}-draggable-layout`);
|
||||
|
||||
const [group] = usePersistedOverlayState("cameraGroup", "default" as string);
|
||||
const [group] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!config) {
|
||||
@ -153,6 +145,11 @@ export default function DraggableGridLayout({
|
||||
useEffect(() => {
|
||||
setIsEditMode(false);
|
||||
setEditGroup(false);
|
||||
// Reset camera tracking state when group changes to prevent the camera-change
|
||||
// effect from incorrectly overwriting the loaded layout
|
||||
setCurrentCameras(undefined);
|
||||
setCurrentIncludeBirdseye(undefined);
|
||||
setCurrentGridLayout(undefined);
|
||||
}, [cameraGroup, setIsEditMode]);
|
||||
|
||||
// camera state
|
||||
@ -176,104 +173,120 @@ export default function DraggableGridLayout({
|
||||
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
||||
);
|
||||
|
||||
const generateLayout = useCallback(() => {
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraNames =
|
||||
includeBirdseye && birdseyeConfig?.enabled
|
||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||
: cameras.map((camera) => camera?.name || "");
|
||||
|
||||
const optionsMap: Layout[] = currentGridLayout
|
||||
? currentGridLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||
: [];
|
||||
|
||||
cameraNames.forEach((cameraName, index) => {
|
||||
const existingLayout = optionsMap.find(
|
||||
(layout) => layout.i === cameraName,
|
||||
);
|
||||
|
||||
// Skip if the camera already exists in the layout
|
||||
if (existingLayout) {
|
||||
const generateLayout = useCallback(
|
||||
(baseLayout: Layout[] | undefined) => {
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
let aspectRatio;
|
||||
let col;
|
||||
const cameraNames =
|
||||
includeBirdseye && birdseyeConfig?.enabled
|
||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||
: cameras.map((camera) => camera?.name || "");
|
||||
|
||||
// Handle "birdseye" camera as a special case
|
||||
if (cameraName === "birdseye") {
|
||||
aspectRatio =
|
||||
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
|
||||
col = 0; // Set birdseye camera in the first column
|
||||
} else {
|
||||
const camera = cameras.find((cam) => cam.name === cameraName);
|
||||
aspectRatio =
|
||||
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
|
||||
col = index % 3; // Regular cameras distributed across columns
|
||||
}
|
||||
const optionsMap: Layout[] = baseLayout
|
||||
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||
: [];
|
||||
|
||||
// Calculate layout options based on aspect ratio
|
||||
const columnsPerPlayer = 4;
|
||||
let height;
|
||||
let width;
|
||||
cameraNames.forEach((cameraName, index) => {
|
||||
const existingLayout = optionsMap.find(
|
||||
(layout) => layout.i === cameraName,
|
||||
);
|
||||
|
||||
if (aspectRatio < 1) {
|
||||
// Portrait
|
||||
height = 2 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
} else if (aspectRatio > 2) {
|
||||
// Wide
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = 2 * columnsPerPlayer;
|
||||
} else {
|
||||
// Landscape
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
}
|
||||
// Skip if the camera already exists in the layout
|
||||
if (existingLayout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
i: cameraName,
|
||||
x: col * width,
|
||||
y: 0, // don't set y, grid does automatically
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
let aspectRatio;
|
||||
let col;
|
||||
|
||||
optionsMap.push(options);
|
||||
});
|
||||
// Handle "birdseye" camera as a special case
|
||||
if (cameraName === "birdseye") {
|
||||
aspectRatio =
|
||||
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
|
||||
col = 0; // Set birdseye camera in the first column
|
||||
} else {
|
||||
const camera = cameras.find((cam) => cam.name === cameraName);
|
||||
aspectRatio =
|
||||
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
|
||||
col = index % 3; // Regular cameras distributed across columns
|
||||
}
|
||||
|
||||
return optionsMap;
|
||||
}, [
|
||||
cameras,
|
||||
isGridLayoutLoaded,
|
||||
currentGridLayout,
|
||||
includeBirdseye,
|
||||
birdseyeConfig,
|
||||
]);
|
||||
// Calculate layout options based on aspect ratio
|
||||
const columnsPerPlayer = 4;
|
||||
let height;
|
||||
let width;
|
||||
|
||||
if (aspectRatio < 1) {
|
||||
// Portrait
|
||||
height = 2 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
} else if (aspectRatio > 2) {
|
||||
// Wide
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = 2 * columnsPerPlayer;
|
||||
} else {
|
||||
// Landscape
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
}
|
||||
|
||||
const options = {
|
||||
i: cameraName,
|
||||
x: col * width,
|
||||
y: 0, // don't set y, grid does automatically
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
|
||||
optionsMap.push(options);
|
||||
});
|
||||
|
||||
return optionsMap;
|
||||
},
|
||||
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGridLayoutLoaded) {
|
||||
if (gridLayout) {
|
||||
// set current grid layout from loaded
|
||||
setCurrentGridLayout(gridLayout);
|
||||
// set current grid layout from loaded, possibly adding new cameras
|
||||
const updatedLayout = generateLayout(gridLayout);
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
// Only save if cameras were added (layout changed)
|
||||
if (!isEqual(updatedLayout, gridLayout)) {
|
||||
setGridLayout(updatedLayout);
|
||||
}
|
||||
// Set camera tracking state so the camera-change effect has a baseline
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
} else {
|
||||
// idb is empty, set it with an initial layout
|
||||
setGridLayout(generateLayout());
|
||||
const newLayout = generateLayout(undefined);
|
||||
setCurrentGridLayout(newLayout);
|
||||
setGridLayout(newLayout);
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isEditMode,
|
||||
gridLayout,
|
||||
currentGridLayout,
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
generateLayout,
|
||||
cameras,
|
||||
includeBirdseye,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only regenerate layout when cameras change WITHIN an already-loaded group
|
||||
// Skip if currentCameras is undefined (means we just switched groups and
|
||||
// the first useEffect hasn't run yet to set things up)
|
||||
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isEqual(cameras, currentCameras) ||
|
||||
includeBirdseye !== currentIncludeBirdseye
|
||||
@ -281,15 +294,17 @@ export default function DraggableGridLayout({
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
|
||||
// set new grid layout in idb
|
||||
setGridLayout(generateLayout());
|
||||
// Regenerate layout based on current layout, adding any new cameras
|
||||
const updatedLayout = generateLayout(currentGridLayout);
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
setGridLayout(updatedLayout);
|
||||
}
|
||||
}, [
|
||||
cameras,
|
||||
includeBirdseye,
|
||||
currentCameras,
|
||||
currentIncludeBirdseye,
|
||||
setCurrentGridLayout,
|
||||
currentGridLayout,
|
||||
generateLayout,
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
@ -624,6 +639,7 @@ export default function DraggableGridLayout({
|
||||
resetPreferredLiveMode(camera.name)
|
||||
}
|
||||
config={config}
|
||||
streamMetadata={streamMetadata}
|
||||
>
|
||||
<LivePlayer
|
||||
key={camera.name}
|
||||
@ -838,6 +854,7 @@ type GridLiveContextMenuProps = {
|
||||
unmuteAll: () => void;
|
||||
resetPreferredLiveMode: () => void;
|
||||
config?: FrigateConfig;
|
||||
streamMetadata?: { [key: string]: LiveStreamMetadata };
|
||||
};
|
||||
|
||||
const GridLiveContextMenu = React.forwardRef<
|
||||
@ -868,6 +885,7 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
unmuteAll,
|
||||
resetPreferredLiveMode,
|
||||
config,
|
||||
streamMetadata,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@ -899,6 +917,7 @@ const GridLiveContextMenu = React.forwardRef<
|
||||
unmuteAll={unmuteAll}
|
||||
resetPreferredLiveMode={resetPreferredLiveMode}
|
||||
config={config}
|
||||
streamMetadata={streamMetadata}
|
||||
>
|
||||
{children}
|
||||
</LiveContextMenu>
|
||||
|
||||
@ -101,7 +101,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import axios from "axios";
|
||||
@ -146,7 +146,7 @@ export default function LiveCameraView({
|
||||
|
||||
// supported features
|
||||
|
||||
const [streamName, setStreamName] = usePersistence<string>(
|
||||
const [streamName, setStreamName] = useUserPersistence<string>(
|
||||
`${camera.name}-stream`,
|
||||
Object.values(camera.live.streams)[0],
|
||||
);
|
||||
@ -279,7 +279,7 @@ export default function LiveCameraView({
|
||||
const [pip, setPip] = useState(false);
|
||||
const [lowBandwidth, setLowBandwidth] = useState(false);
|
||||
|
||||
const [playInBackground, setPlayInBackground] = usePersistence<boolean>(
|
||||
const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
|
||||
`${camera.name}-background-play`,
|
||||
false,
|
||||
);
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
CameraConfig,
|
||||
@ -78,7 +78,7 @@ export default function LiveDashboardView({
|
||||
|
||||
// layout
|
||||
|
||||
const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">(
|
||||
const [mobileLayout, setMobileLayout] = useUserPersistence<"grid" | "list">(
|
||||
"live-layout",
|
||||
isDesktop ? "grid" : "list",
|
||||
);
|
||||
@ -211,8 +211,8 @@ export default function LiveDashboardView({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
|
||||
|
||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||
useStreamingSettings();
|
||||
@ -265,6 +265,7 @@ export default function LiveDashboardView({
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
streamMetadata,
|
||||
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
|
||||
|
||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||
@ -650,6 +651,12 @@ export default function LiveDashboardView({
|
||||
setIsEditMode={setIsEditMode}
|
||||
fullscreen={fullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
preferredLiveModes={preferredLiveModes}
|
||||
setPreferredLiveModes={setPreferredLiveModes}
|
||||
resetPreferredLiveMode={resetPreferredLiveMode}
|
||||
isRestreamedStates={isRestreamedStates}
|
||||
supportsAudioOutputStates={supportsAudioOutputStates}
|
||||
streamMetadata={streamMetadata}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -478,33 +478,32 @@ export default function AuthenticationView({
|
||||
<TableCell className="text-right">
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{user.username !== "admin" &&
|
||||
user.username !== "viewer" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username);
|
||||
setSelectedUserRole(
|
||||
user.role || "viewer",
|
||||
);
|
||||
setShowRoleChange(true);
|
||||
}}
|
||||
>
|
||||
<LuUserCog className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
{t("role.title", { ns: "common" })}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("users.table.changeRole")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{user.username !== "admin" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2"
|
||||
onClick={() => {
|
||||
setSelectedUser(user.username);
|
||||
setSelectedUserRole(
|
||||
user.role || "viewer",
|
||||
);
|
||||
setShowRoleChange(true);
|
||||
}}
|
||||
>
|
||||
<LuUserCog className="size-3.5" />
|
||||
<span className="ml-1.5 hidden sm:inline-block">
|
||||
{t("role.title", { ns: "common" })}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("users.table.changeRole")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
||||
import useSWR from "swr";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@ -104,7 +104,7 @@ export default function ObjectSettingsView({
|
||||
},
|
||||
];
|
||||
|
||||
const [options, setOptions, optionsLoaded] = usePersistence<Options>(
|
||||
const [options, setOptions, optionsLoaded] = useUserPersistence<Options>(
|
||||
`${selectedCamera}-feed`,
|
||||
emptyObject,
|
||||
);
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useContext, useEffect } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { toast } from "sonner";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { del as delData } from "idb-keyval";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
useUserPersistence,
|
||||
deleteUserNamespacedKey,
|
||||
} from "@/hooks/use-user-persistence";
|
||||
import { isSafari } from "react-device-detect";
|
||||
import {
|
||||
Select,
|
||||
@ -19,6 +21,7 @@ import {
|
||||
SelectTrigger,
|
||||
} from "../../components/ui/select";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
|
||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||
@ -26,13 +29,16 @@ const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||
export default function UiSettingsView() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { auth } = useContext(AuthContext);
|
||||
const username = auth?.user?.username;
|
||||
|
||||
const clearStoredLayouts = useCallback(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Object.entries(config.camera_groups).forEach(async (value) => {
|
||||
await delData(`${value[0]}-draggable-layout`)
|
||||
await deleteUserNamespacedKey(`${value[0]}-draggable-layout`, username)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
t("general.toast.success.clearStoredLayout", {
|
||||
@ -56,14 +62,14 @@ export default function UiSettingsView() {
|
||||
);
|
||||
});
|
||||
});
|
||||
}, [config, t]);
|
||||
}, [config, t, username]);
|
||||
|
||||
const clearStreamingSettings = useCallback(async () => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await delData(`streaming-settings`)
|
||||
await deleteUserNamespacedKey(`streaming-settings`, username)
|
||||
.then(() => {
|
||||
toast.success(t("general.toast.success.clearStreamingSettings"), {
|
||||
position: "top-center",
|
||||
@ -83,7 +89,7 @@ export default function UiSettingsView() {
|
||||
},
|
||||
);
|
||||
});
|
||||
}, [config, t]);
|
||||
}, [config, t, username]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.general");
|
||||
@ -91,15 +97,15 @@ export default function UiSettingsView() {
|
||||
|
||||
// settings
|
||||
|
||||
const [autoLive, setAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [cameraNames, setCameraName] = usePersistence(
|
||||
const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
const [cameraNames, setCameraName] = useUserPersistence(
|
||||
"displayCameraNames",
|
||||
false,
|
||||
);
|
||||
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
|
||||
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
|
||||
const [fallbackTimeout, setFallbackTimeout] = usePersistence(
|
||||
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
|
||||
const [fallbackTimeout, setFallbackTimeout] = useUserPersistence(
|
||||
"liveFallbackTimeout",
|
||||
3,
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user