mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
Compare commits
6 Commits
0b5b58082d
...
378f3c7332
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
378f3c7332 | ||
|
|
9d4aac2b8e | ||
|
|
aa09132dfd | ||
|
|
24766ce427 | ||
|
|
97b29d177a | ||
|
|
43669f5304 |
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@ -15,7 +15,7 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: 3.9
|
PYTHON_VERSION: 3.11
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
amd64_build:
|
amd64_build:
|
||||||
@ -23,7 +23,7 @@ jobs:
|
|||||||
name: AMD64 Build
|
name: AMD64 Build
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up QEMU and Buildx
|
- name: Set up QEMU and Buildx
|
||||||
@ -47,7 +47,7 @@ jobs:
|
|||||||
name: ARM Build
|
name: ARM Build
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up QEMU and Buildx
|
- name: Set up QEMU and Buildx
|
||||||
@ -82,7 +82,7 @@ jobs:
|
|||||||
name: Jetson Jetpack 6
|
name: Jetson Jetpack 6
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up QEMU and Buildx
|
- name: Set up QEMU and Buildx
|
||||||
@ -113,7 +113,7 @@ jobs:
|
|||||||
- amd64_build
|
- amd64_build
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up QEMU and Buildx
|
- name: Set up QEMU and Buildx
|
||||||
@ -155,7 +155,7 @@ jobs:
|
|||||||
- arm64_build
|
- arm64_build
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up QEMU and Buildx
|
- name: Set up QEMU and Buildx
|
||||||
@ -180,7 +180,7 @@ jobs:
|
|||||||
- arm64_build
|
- arm64_build
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up QEMU and Buildx
|
- name: Set up QEMU and Buildx
|
||||||
|
|||||||
8
.github/workflows/pull_request.yml
vendored
8
.github/workflows/pull_request.yml
vendored
@ -16,7 +16,7 @@ jobs:
|
|||||||
name: Web - Lint
|
name: Web - Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@master
|
- uses: actions/setup-node@master
|
||||||
@ -32,7 +32,7 @@ jobs:
|
|||||||
name: Web - Test
|
name: Web - Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@master
|
- uses: actions/setup-node@master
|
||||||
@ -52,7 +52,7 @@ jobs:
|
|||||||
name: Python Checks
|
name: Python Checks
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repository
|
- name: Check out the repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
@ -75,7 +75,7 @@ jobs:
|
|||||||
name: Python Tests
|
name: Python Tests
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@master
|
- uses: actions/setup-node@master
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- id: lowercaseRepo
|
- id: lowercaseRepo
|
||||||
|
|||||||
53
README_CN.md
53
README_CN.md
@ -1,28 +1,31 @@
|
|||||||
<p align="center">
|
<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>
|
</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/">
|
<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="翻译状态" />
|
<img src="https://hosted.weblate.org/widget/frigate-nvr/-/zh_Hans/svg-badge.svg" alt="翻译状态" />
|
||||||
</a>
|
</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,并且可以以极低的耗电实现更优的性能。
|
强烈推荐使用 GPU 或者 AI 加速器(例如[Google Coral 加速器](https://coral.ai/products/) 或者 [Hailo](https://hailo.ai/)等)。它们的运行效率远远高于现在的顶级 CPU,并且功耗也极低。
|
||||||
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与Home Assistant紧密集成
|
|
||||||
- 设计上通过仅在必要时和必要地点寻找物体,最大限度地减少资源使用并最大化性能
|
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与 Home Assistant 紧密集成
|
||||||
|
- 设计上通过仅在必要时和必要地点寻找目标,最大限度地减少资源使用并最大化性能
|
||||||
- 大量利用多进程处理,强调实时性而非处理每一帧
|
- 大量利用多进程处理,强调实时性而非处理每一帧
|
||||||
- 使用非常低开销的运动检测来确定运行物体检测的位置
|
- 使用非常低开销的画面变动检测(也叫运动检测)来确定运行目标检测的位置
|
||||||
- 使用TensorFlow进行物体检测,运行在单独的进程中以达到最大FPS
|
- 使用 TensorFlow 进行目标检测,并运行在单独的进程中以达到最大 FPS
|
||||||
- 通过MQTT进行通信,便于集成到其他系统中
|
- 通过 MQTT 进行通信,便于集成到其他系统中
|
||||||
- 根据检测到的物体设置保留时间进行视频录制
|
- 根据检测到的物体设置保留时间进行视频录制
|
||||||
- 24/7全天候录制
|
- 24/7 全天候录制
|
||||||
- 通过RTSP重新流传输以减少摄像头的连接数
|
- 通过 RTSP 重新流传输以减少摄像头的连接数
|
||||||
- 支持WebRTC和MSE,实现低延迟的实时观看
|
- 支持 WebRTC 和 MSE,实现低延迟的实时观看
|
||||||
|
|
||||||
## 社区中文翻译文档
|
## 社区中文翻译文档
|
||||||
|
|
||||||
@ -32,39 +35,55 @@
|
|||||||
|
|
||||||
如果您想通过捐赠支持开发,请使用 [Github Sponsors](https://github.com/sponsors/blakeblackshear)。
|
如果您想通过捐赠支持开发,请使用 [Github Sponsors](https://github.com/sponsors/blakeblackshear)。
|
||||||
|
|
||||||
|
## 协议
|
||||||
|
|
||||||
|
本项目采用 **MIT 许可证**授权。
|
||||||
|
**代码部分**:本代码库中的源代码、配置文件和文档均遵循 [MIT 许可证](LICENSE)。您可以自由使用、修改和分发这些代码,但必须保留原始版权声明。
|
||||||
|
|
||||||
|
**商标部分**:“Frigate”名称、“Frigate NVR”品牌以及 Frigate 的 Logo 为 **Frigate LLC 的商标**,**不在** MIT 许可证覆盖范围内。
|
||||||
|
有关品牌资产的规范使用详情,请参阅我们的[《商标政策》](TRADEMARK.md)。
|
||||||
|
|
||||||
## 截图
|
## 截图
|
||||||
|
|
||||||
### 实时监控面板
|
### 实时监控面板
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<img width="800" alt="实时监控面板" src="https://github.com/blakeblackshear/frigate/assets/569905/5e713cb9-9db5-41dc-947a-6937c3bc376e">
|
<img width="800" alt="实时监控面板" src="https://github.com/blakeblackshear/frigate/assets/569905/5e713cb9-9db5-41dc-947a-6937c3bc376e">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### 简单的核查工作流程
|
### 简单的核查工作流程
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<img width="800" alt="简单的审查工作流程" src="https://github.com/blakeblackshear/frigate/assets/569905/6fed96e8-3b18-40e5-9ddc-31e6f3c9f2ff">
|
<img width="800" alt="简单的审查工作流程" src="https://github.com/blakeblackshear/frigate/assets/569905/6fed96e8-3b18-40e5-9ddc-31e6f3c9f2ff">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### 多摄像头可按时间轴查看
|
### 多摄像头可按时间轴查看
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<img width="800" alt="多摄像头可按时间轴查看" src="https://github.com/blakeblackshear/frigate/assets/569905/d6788a15-0eeb-4427-a8d4-80b93cae3d74">
|
<img width="800" alt="多摄像头可按时间轴查看" src="https://github.com/blakeblackshear/frigate/assets/569905/d6788a15-0eeb-4427-a8d4-80b93cae3d74">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### 内置遮罩和区域编辑器
|
### 内置遮罩和区域编辑器
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<img width="800" alt="内置遮罩和区域编辑器" src="https://github.com/blakeblackshear/frigate/assets/569905/d7885fc3-bfe6-452f-b7d0-d957cb3e31f5">
|
<img width="800" alt="内置遮罩和区域编辑器" src="https://github.com/blakeblackshear/frigate/assets/569905/d7885fc3-bfe6-452f-b7d0-d957cb3e31f5">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
## 翻译
|
## 翻译
|
||||||
|
|
||||||
我们使用 [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) 平台提供翻译支持,欢迎参与进来一起完善。
|
我们使用 [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
|
Bilibili:https://space.bilibili.com/3546894915602564
|
||||||
|
|
||||||
|
|
||||||
## 中文社区赞助商
|
## 中文社区赞助商
|
||||||
|
|
||||||
[](https://edgeone.ai/zh?from=github)
|
[](https://edgeone.ai/zh?from=github)
|
||||||
本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助
|
本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Copyright © 2025 Frigate LLC.**
|
||||||
|
|||||||
@ -15,7 +15,7 @@ ARG AMDGPU
|
|||||||
|
|
||||||
RUN apt update -qq && \
|
RUN apt update -qq && \
|
||||||
apt install -y wget gpg && \
|
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 install -y ./rocm.deb && \
|
||||||
apt update && \
|
apt update && \
|
||||||
apt install -qq -y rocm
|
apt install -qq -y rocm
|
||||||
|
|||||||
@ -2,7 +2,7 @@ variable "AMDGPU" {
|
|||||||
default = "gfx900"
|
default = "gfx900"
|
||||||
}
|
}
|
||||||
variable "ROCM" {
|
variable "ROCM" {
|
||||||
default = "7.1.0"
|
default = "7.1.1"
|
||||||
}
|
}
|
||||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||||
default = ""
|
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.
|
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.
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
.alert {
|
.alert {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #fff8e6;
|
background: #fff8e6;
|
||||||
border-bottom: 1px solid #ffd166;
|
border-bottom: 1px solid #ffd166;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert a {
|
[data-theme="dark"] .alert {
|
||||||
color: #1890ff;
|
background: #3b2f0b;
|
||||||
font-weight: 500;
|
border-bottom: 1px solid #665c22;
|
||||||
margin-left: 6px;
|
}
|
||||||
}
|
|
||||||
|
.alert a {
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|||||||
@ -99,6 +99,42 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
|||||||
if self.inference_speed:
|
if self.inference_speed:
|
||||||
self.inference_speed.update(duration)
|
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:
|
def verify_state_change(self, camera: str, detected_state: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Verify state change requires 3 consecutive identical states before publishing.
|
Verify state change requires 3 consecutive identical states before publishing.
|
||||||
@ -212,14 +248,16 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self.interpreter is None:
|
if self.interpreter is None:
|
||||||
write_classification_attempt(
|
# When interpreter is None, always save (score is 0.0, which is < 1.0)
|
||||||
self.train_dir,
|
if self._should_save_image(camera, "unknown", 0.0):
|
||||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
write_classification_attempt(
|
||||||
"none-none",
|
self.train_dir,
|
||||||
now,
|
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||||
"unknown",
|
"none-none",
|
||||||
0.0,
|
now,
|
||||||
)
|
"unknown",
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
input = np.expand_dims(resized_frame, axis=0)
|
input = np.expand_dims(resized_frame, axis=0)
|
||||||
@ -236,14 +274,17 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
|||||||
score = round(probs[best_id], 2)
|
score = round(probs[best_id], 2)
|
||||||
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
self.__update_metrics(datetime.datetime.now().timestamp() - now)
|
||||||
|
|
||||||
write_classification_attempt(
|
detected_state = self.labelmap[best_id]
|
||||||
self.train_dir,
|
|
||||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
if self._should_save_image(camera, detected_state, score):
|
||||||
"none-none",
|
write_classification_attempt(
|
||||||
now,
|
self.train_dir,
|
||||||
self.labelmap[best_id],
|
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||||
score,
|
"none-none",
|
||||||
)
|
now,
|
||||||
|
detected_state,
|
||||||
|
score,
|
||||||
|
)
|
||||||
|
|
||||||
if score < self.model_config.threshold:
|
if score < self.model_config.threshold:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@ -251,7 +292,6 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
detected_state = self.labelmap[best_id]
|
|
||||||
verified_state = self.verify_state_change(camera, detected_state)
|
verified_state = self.verify_state_change(camera, detected_state)
|
||||||
|
|
||||||
if verified_state is not None:
|
if verified_state is not None:
|
||||||
|
|||||||
@ -190,7 +190,11 @@ class OnvifController:
|
|||||||
ptz: ONVIFService = await onvif.create_ptz_service()
|
ptz: ONVIFService = await onvif.create_ptz_service()
|
||||||
self.cams[camera_name]["ptz"] = ptz
|
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
|
self.cams[camera_name]["imaging"] = imaging
|
||||||
try:
|
try:
|
||||||
video_sources = await media.GetVideoSources()
|
video_sources = await media.GetVideoSources()
|
||||||
@ -381,7 +385,10 @@ class OnvifController:
|
|||||||
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
|
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:
|
try:
|
||||||
imaging_capabilities = await imaging.GetImagingSettings(
|
imaging_capabilities = await imaging.GetImagingSettings(
|
||||||
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
|
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
|
||||||
@ -421,6 +428,7 @@ class OnvifController:
|
|||||||
if (
|
if (
|
||||||
"focus" in self.cams[camera_name]["features"]
|
"focus" in self.cams[camera_name]["features"]
|
||||||
and self.cams[camera_name]["video_source_token"]
|
and self.cams[camera_name]["video_source_token"]
|
||||||
|
and self.cams[camera_name]["imaging"] is not None
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
|
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
|
||||||
@ -648,6 +656,7 @@ class OnvifController:
|
|||||||
if (
|
if (
|
||||||
"focus" not in self.cams[camera_name]["features"]
|
"focus" not in self.cams[camera_name]["features"]
|
||||||
or not self.cams[camera_name]["video_source_token"]
|
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.")
|
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
|
||||||
return
|
return
|
||||||
|
|||||||
@ -124,45 +124,50 @@ def capture_frames(
|
|||||||
config_subscriber.check_for_updates()
|
config_subscriber.check_for_updates()
|
||||||
return config.enabled
|
return config.enabled
|
||||||
|
|
||||||
while not stop_event.is_set():
|
try:
|
||||||
if not get_enabled_state():
|
while not stop_event.is_set():
|
||||||
logger.debug(f"Stopping capture thread for disabled {config.name}")
|
if not get_enabled_state():
|
||||||
break
|
logger.debug(f"Stopping capture thread for disabled {config.name}")
|
||||||
|
|
||||||
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
|
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(
|
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
|
frame_rate.update()
|
||||||
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
|
# 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):
|
class CameraWatchdog(threading.Thread):
|
||||||
@ -234,6 +239,16 @@ class CameraWatchdog(threading.Thread):
|
|||||||
else:
|
else:
|
||||||
self.ffmpeg_detect_process.wait()
|
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(
|
self.logger.error(
|
||||||
"The following ffmpeg logs include the last 100 lines prior to exit."
|
"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 { LuSettings } from "react-icons/lu";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
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 AutoUpdatingCameraImage from "./AutoUpdatingCameraImage";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ export default function DebugCameraImage({
|
|||||||
}: DebugCameraImageProps) {
|
}: DebugCameraImageProps) {
|
||||||
const { t } = useTranslation(["components/camera"]);
|
const { t } = useTranslation(["components/camera"]);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [options, setOptions] = usePersistence<Options>(
|
const [options, setOptions] = useUserPersistence<Options>(
|
||||||
`${cameraConfig?.name}-feed`,
|
`${cameraConfig?.name}-feed`,
|
||||||
emptyObject,
|
emptyObject,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import { baseUrl } from "@/api/baseUrl";
|
|||||||
import { VideoPreview } from "../preview/ScrubbablePreview";
|
import { VideoPreview } from "../preview/ScrubbablePreview";
|
||||||
import { useApiHost } from "@/api";
|
import { useApiHost } from "@/api";
|
||||||
import { isDesktop, isSafari } from "react-device-detect";
|
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 { Skeleton } from "../ui/skeleton";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { FaCircleCheck } from "react-icons/fa6";
|
import { FaCircleCheck } from "react-icons/fa6";
|
||||||
@ -112,7 +112,7 @@ export function AnimatedEventCard({
|
|||||||
|
|
||||||
// image behavior
|
// image behavior
|
||||||
|
|
||||||
const [alertVideos, _, alertVideosLoaded] = usePersistence(
|
const [alertVideos, _, alertVideosLoaded] = useUserPersistence(
|
||||||
"alertVideos",
|
"alertVideos",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -37,7 +37,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LuPlus, LuX } from "react-icons/lu";
|
import { LuPlus, LuX } from "react-icons/lu";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR, { mutate } from "swr";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
type ClassificationModelEditDialogProps = {
|
type ClassificationModelEditDialogProps = {
|
||||||
@ -240,15 +240,61 @@ export default function ClassificationModelEditDialog({
|
|||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// State model - update classes
|
const stateData = data as StateFormData;
|
||||||
// Note: For state models, updating classes requires renaming categories
|
const newClasses = stateData.classes.filter(
|
||||||
// which is handled through the dataset API, not the config API
|
(c) => c.trim().length > 0,
|
||||||
// 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 oldClasses = dataset?.categories
|
||||||
|
? Object.keys(dataset.categories).filter((key) => key !== "none")
|
||||||
|
: [];
|
||||||
|
|
||||||
toast.info(t("edit.stateClassesInfo"), {
|
const renameMap = new Map<string, string>();
|
||||||
position: "top-center",
|
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();
|
onSuccess();
|
||||||
@ -256,8 +302,10 @@ export default function ClassificationModelEditDialog({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as {
|
const error = err as {
|
||||||
response?: { data?: { message?: string; detail?: string } };
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
message?: string;
|
||||||
};
|
};
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
|
error.message ||
|
||||||
error.response?.data?.message ||
|
error.response?.data?.message ||
|
||||||
error.response?.data?.detail ||
|
error.response?.data?.detail ||
|
||||||
"Unknown error";
|
"Unknown error";
|
||||||
@ -268,7 +316,7 @@ export default function ClassificationModelEditDialog({
|
|||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isObjectModel, model, t, onSuccess, onClose],
|
[isObjectModel, model, dataset, t, onSuccess, onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { MdHome } from "react-icons/md";
|
import { MdHome } from "react-icons/md";
|
||||||
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
|
||||||
import { Button, buttonVariants } from "../ui/button";
|
import { Button, buttonVariants } from "../ui/button";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
@ -57,7 +56,7 @@ import { Toaster } from "@/components/ui/sonner";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import ActivityIndicator from "../indicators/activity-indicator";
|
import ActivityIndicator from "../indicators/activity-indicator";
|
||||||
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
|
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 { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import * as LuIcons from "react-icons/lu";
|
import * as LuIcons from "react-icons/lu";
|
||||||
@ -79,6 +78,7 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||||
|
|
||||||
type CameraGroupSelectorProps = {
|
type CameraGroupSelectorProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -109,9 +109,9 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
|||||||
[timeoutId],
|
[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",
|
"cameraGroup",
|
||||||
"default" as string,
|
"default" as string,
|
||||||
);
|
);
|
||||||
@ -276,7 +276,7 @@ function NewGroupDialog({
|
|||||||
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
|
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const [, , , deleteGridLayout] = usePersistence(
|
const [, , , deleteGridLayout] = useUserPersistence(
|
||||||
`${activeGroup}-draggable-layout`,
|
`${activeGroup}-draggable-layout`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { TooltipPortal } from "@radix-ui/react-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 { SaveSearchDialog } from "./SaveSearchDialog";
|
||||||
import { DeleteSearchDialog } from "./DeleteSearchDialog";
|
import { DeleteSearchDialog } from "./DeleteSearchDialog";
|
||||||
import {
|
import {
|
||||||
@ -128,9 +128,8 @@ export default function InputWithTags({
|
|||||||
|
|
||||||
// TODO: search history from browser storage
|
// TODO: search history from browser storage
|
||||||
|
|
||||||
const [searchHistory, setSearchHistory, searchHistoryLoaded] = usePersistence<
|
const [searchHistory, setSearchHistory, searchHistoryLoaded] =
|
||||||
SavedSearchQuery[]
|
useUserPersistence<SavedSearchQuery[]>("frigate-search-history");
|
||||||
>("frigate-search-history");
|
|
||||||
|
|
||||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||||
|
import { LiveStreamMetadata } from "@/types/live";
|
||||||
|
|
||||||
type LiveContextMenuProps = {
|
type LiveContextMenuProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -68,6 +69,7 @@ type LiveContextMenuProps = {
|
|||||||
resetPreferredLiveMode: () => void;
|
resetPreferredLiveMode: () => void;
|
||||||
config?: FrigateConfig;
|
config?: FrigateConfig;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
streamMetadata?: { [key: string]: LiveStreamMetadata };
|
||||||
};
|
};
|
||||||
export default function LiveContextMenu({
|
export default function LiveContextMenu({
|
||||||
className,
|
className,
|
||||||
@ -88,6 +90,7 @@ export default function LiveContextMenu({
|
|||||||
resetPreferredLiveMode,
|
resetPreferredLiveMode,
|
||||||
config,
|
config,
|
||||||
children,
|
children,
|
||||||
|
streamMetadata,
|
||||||
}: LiveContextMenuProps) {
|
}: LiveContextMenuProps) {
|
||||||
const { t } = useTranslation("views/live");
|
const { t } = useTranslation("views/live");
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
@ -558,6 +561,7 @@ export default function LiveContextMenu({
|
|||||||
setGroupStreamingSettings={setGroupStreamingSettings}
|
setGroupStreamingSettings={setGroupStreamingSettings}
|
||||||
setIsDialogOpen={setShowSettings}
|
setIsDialogOpen={setShowSettings}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
|
streamMetadata={streamMetadata}
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { FaCircle } from "react-icons/fa";
|
|||||||
import { getUTCOffset } from "@/utils/dateUtil";
|
import { getUTCOffset } from "@/utils/dateUtil";
|
||||||
import { type DayButtonProps, TZDate } from "react-day-picker";
|
import { type DayButtonProps, TZDate } from "react-day-picker";
|
||||||
import { LAST_24_HOURS_KEY } from "@/types/filter";
|
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 { cn } from "@/lib/utils";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -27,7 +27,7 @@ export default function ReviewActivityCalendar({
|
|||||||
}: ReviewActivityCalendarProps) {
|
}: ReviewActivityCalendarProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const timezone = useTimezone(config);
|
const timezone = useTimezone(config);
|
||||||
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
|
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||||
|
|
||||||
const disabledDates = useMemo(() => {
|
const disabledDates = useMemo(() => {
|
||||||
const tomorrow = new Date();
|
const tomorrow = new Date();
|
||||||
@ -176,7 +176,7 @@ export function TimezoneAwareCalendar({
|
|||||||
selectedDay,
|
selectedDay,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: TimezoneAwareCalendarProps) {
|
}: TimezoneAwareCalendarProps) {
|
||||||
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
|
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||||
|
|
||||||
const timezoneOffset = useMemo(
|
const timezoneOffset = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
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 { cn } from "@/lib/utils";
|
||||||
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
|
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -210,9 +210,9 @@ export default function HlsVideoPlayer({
|
|||||||
|
|
||||||
const [tallCamera, setTallCamera] = useState(false);
|
const [tallCamera, setTallCamera] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(true);
|
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 [volume, setVolume] = useOverlayState("playerVolume", 1.0);
|
||||||
const [defaultPlaybackRate] = usePersistence("playbackRate", 1);
|
const [defaultPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||||
const [playbackRate, setPlaybackRate] = useOverlayState(
|
const [playbackRate, setPlaybackRate] = useOverlayState(
|
||||||
"playbackRate",
|
"playbackRate",
|
||||||
defaultPlaybackRate ?? 1,
|
defaultPlaybackRate ?? 1,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { usePersistence } from "@/hooks/use-persistence";
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||||
import {
|
import {
|
||||||
LivePlayerError,
|
LivePlayerError,
|
||||||
PlayerStatsType,
|
PlayerStatsType,
|
||||||
@ -72,7 +72,10 @@ function MSEPlayer({
|
|||||||
const [errorCount, setErrorCount] = useState<number>(0);
|
const [errorCount, setErrorCount] = useState<number>(0);
|
||||||
const totalBytesLoaded = useRef(0);
|
const totalBytesLoaded = useRef(0);
|
||||||
|
|
||||||
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3);
|
const [fallbackTimeout] = useUserPersistence<number>(
|
||||||
|
"liveFallbackTimeout",
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
|||||||
type CameraStreamingDialogProps = {
|
type CameraStreamingDialogProps = {
|
||||||
camera: string;
|
camera: string;
|
||||||
groupStreamingSettings: GroupStreamingSettings;
|
groupStreamingSettings: GroupStreamingSettings;
|
||||||
|
streamMetadata?: { [key: string]: LiveStreamMetadata };
|
||||||
setGroupStreamingSettings: React.Dispatch<
|
setGroupStreamingSettings: React.Dispatch<
|
||||||
React.SetStateAction<GroupStreamingSettings>
|
React.SetStateAction<GroupStreamingSettings>
|
||||||
>;
|
>;
|
||||||
@ -48,6 +49,7 @@ type CameraStreamingDialogProps = {
|
|||||||
export function CameraStreamingDialog({
|
export function CameraStreamingDialog({
|
||||||
camera,
|
camera,
|
||||||
groupStreamingSettings,
|
groupStreamingSettings,
|
||||||
|
streamMetadata,
|
||||||
setGroupStreamingSettings,
|
setGroupStreamingSettings,
|
||||||
setIsDialogOpen,
|
setIsDialogOpen,
|
||||||
onSave,
|
onSave,
|
||||||
@ -76,12 +78,7 @@ export function CameraStreamingDialog({
|
|||||||
[config, streamName],
|
[config, streamName],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
|
const cameraMetadata = streamName ? streamMetadata?.[streamName] : undefined;
|
||||||
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
|
||||||
{
|
|
||||||
revalidateOnFocus: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const supportsAudioOutput = useMemo(() => {
|
const supportsAudioOutput = useMemo(() => {
|
||||||
if (!cameraMetadata) {
|
if (!cameraMetadata) {
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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 { isDesktop } from "react-device-detect";
|
||||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||||
@ -58,7 +58,7 @@ export default function DetailStream({
|
|||||||
const effectiveTime = currentTime - annotationOffset / 1000;
|
const effectiveTime = currentTime - annotationOffset / 1000;
|
||||||
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
||||||
const [controlsExpanded, setControlsExpanded] = useState(false);
|
const [controlsExpanded, setControlsExpanded] = useState(false);
|
||||||
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
|
const [alwaysExpandActive, setAlwaysExpandActive] = useUserPersistence(
|
||||||
"detailStreamActiveExpanded",
|
"detailStreamActiveExpanded",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { AllGroupsStreamingSettings } from "@/types/frigateConfig";
|
import { AllGroupsStreamingSettings } from "@/types/frigateConfig";
|
||||||
import { usePersistence } from "@/hooks/use-persistence";
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||||
|
|
||||||
type StreamingSettingsContextType = {
|
type StreamingSettingsContextType = {
|
||||||
allGroupsStreamingSettings: AllGroupsStreamingSettings;
|
allGroupsStreamingSettings: AllGroupsStreamingSettings;
|
||||||
@ -29,7 +29,7 @@ export function StreamingSettingsProvider({
|
|||||||
persistedGroupStreamingSettings,
|
persistedGroupStreamingSettings,
|
||||||
setPersistedGroupStreamingSettings,
|
setPersistedGroupStreamingSettings,
|
||||||
isPersistedStreamingSettingsLoaded,
|
isPersistedStreamingSettingsLoaded,
|
||||||
] = usePersistence<AllGroupsStreamingSettings>("streaming-settings");
|
] = useUserPersistence<AllGroupsStreamingSettings>("streaming-settings");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPersistedStreamingSettingsLoaded) {
|
if (isPersistedStreamingSettingsLoaded) {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { baseUrl } from "@/api/baseUrl";
|
|
||||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { useCallback, useEffect, useState, useMemo } from "react";
|
import { useCallback, useEffect, useState, useMemo } from "react";
|
||||||
import useSWR from "swr";
|
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(
|
export default function useCameraLiveMode(
|
||||||
cameras: CameraConfig[],
|
cameras: CameraConfig[],
|
||||||
@ -11,9 +11,9 @@ export default function useCameraLiveMode(
|
|||||||
) {
|
) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
// Get comma-separated list of restreamed stream names for SWR key
|
// Compute which streams need metadata (restreamed streams only)
|
||||||
const restreamedStreamsKey = useMemo(() => {
|
const restreamedStreamNames = useMemo(() => {
|
||||||
if (!cameras || !config) return null;
|
if (!cameras || !config) return [];
|
||||||
|
|
||||||
const streamNames = new Set<string>();
|
const streamNames = new Set<string>();
|
||||||
cameras.forEach((camera) => {
|
cameras.forEach((camera) => {
|
||||||
@ -32,56 +32,13 @@ export default function useCameraLiveMode(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return streamNames.size > 0
|
return Array.from(streamNames);
|
||||||
? Array.from(streamNames).sort().join(",")
|
|
||||||
: null;
|
|
||||||
}, [cameras, config, activeStreams]);
|
}, [cameras, config, activeStreams]);
|
||||||
|
|
||||||
const streamsFetcher = useCallback(async (key: string) => {
|
// Fetch stream metadata with deferred loading (doesn't block initial render)
|
||||||
const streamNames = key.split(",");
|
const streamMetadata = useDeferredStreamMetadata(restreamedStreamNames);
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Compute live mode states
|
||||||
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
||||||
[key: string]: LivePlayerMode;
|
[key: string]: LivePlayerMode;
|
||||||
}>({});
|
}>({});
|
||||||
@ -122,10 +79,10 @@ export default function useCameraLiveMode(
|
|||||||
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
|
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
|
||||||
}
|
}
|
||||||
|
|
||||||
// check each stream for audio support
|
// Check each stream for audio support
|
||||||
if (isRestreamed) {
|
if (isRestreamed) {
|
||||||
Object.values(camera.live.streams).forEach((streamName) => {
|
Object.values(camera.live.streams).forEach((streamName) => {
|
||||||
const metadata = allStreamMetadata?.[streamName];
|
const metadata = streamMetadata[streamName];
|
||||||
newSupportsAudioOutputStates[streamName] = {
|
newSupportsAudioOutputStates[streamName] = {
|
||||||
supportsAudio: metadata
|
supportsAudio: metadata
|
||||||
? metadata.producers.find(
|
? metadata.producers.find(
|
||||||
@ -150,7 +107,7 @@ export default function useCameraLiveMode(
|
|||||||
setPreferredLiveModes(newPreferredLiveModes);
|
setPreferredLiveModes(newPreferredLiveModes);
|
||||||
setIsRestreamedStates(newIsRestreamedStates);
|
setIsRestreamedStates(newIsRestreamedStates);
|
||||||
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
|
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
|
||||||
}, [cameras, config, windowVisible, allStreamMetadata]);
|
}, [cameras, config, windowVisible, streamMetadata]);
|
||||||
|
|
||||||
const resetPreferredLiveMode = useCallback(
|
const resetPreferredLiveMode = useCallback(
|
||||||
(cameraName: string) => {
|
(cameraName: string) => {
|
||||||
@ -180,5 +137,6 @@ export default function useCameraLiveMode(
|
|||||||
resetPreferredLiveMode,
|
resetPreferredLiveMode,
|
||||||
isRestreamedStates,
|
isRestreamedStates,
|
||||||
supportsAudioOutputStates,
|
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 { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { usePersistence } from "./use-persistence";
|
import { usePersistence } from "./use-persistence";
|
||||||
|
import { useUserPersistence } from "./use-user-persistence";
|
||||||
|
import { AuthContext } from "@/context/auth-context";
|
||||||
|
|
||||||
export function useOverlayState<S>(
|
export function useOverlayState<S>(
|
||||||
key: string,
|
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>(): [
|
export function useHashState<S extends string>(): [
|
||||||
S | undefined,
|
S | undefined,
|
||||||
(value: S) => void,
|
(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 { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||||
import { useTimezone } from "@/hooks/use-date-utils";
|
import { useTimezone } from "@/hooks/use-date-utils";
|
||||||
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
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 { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { RecordingStartingPoint } from "@/types/record";
|
import { RecordingStartingPoint } from "@/types/record";
|
||||||
import {
|
import {
|
||||||
@ -42,7 +42,10 @@ export default function Events() {
|
|||||||
"alert",
|
"alert",
|
||||||
);
|
);
|
||||||
|
|
||||||
const [showReviewed, setShowReviewed] = usePersistence("showReviewed", false);
|
const [showReviewed, setShowReviewed] = useUserPersistence(
|
||||||
|
"showReviewed",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>(
|
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>(
|
||||||
"recording",
|
"recording",
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|||||||
import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar";
|
import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar";
|
||||||
import { useApiFilterArgs } from "@/hooks/use-api-filter";
|
import { useApiFilterArgs } from "@/hooks/use-api-filter";
|
||||||
import { useTimezone } from "@/hooks/use-date-utils";
|
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 { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
|
import { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
|
||||||
import { ModelState } from "@/types/ws";
|
import { ModelState } from "@/types/ws";
|
||||||
@ -47,7 +47,10 @@ export default function Explore() {
|
|||||||
|
|
||||||
// grid
|
// grid
|
||||||
|
|
||||||
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
|
const [columnCount, setColumnCount] = useUserPersistence(
|
||||||
|
"exploreGridColumns",
|
||||||
|
4,
|
||||||
|
);
|
||||||
const gridColumns = useMemo(() => {
|
const gridColumns = useMemo(() => {
|
||||||
if (isMobileOnly) {
|
if (isMobileOnly) {
|
||||||
return 2;
|
return 2;
|
||||||
@ -57,7 +60,7 @@ export default function Explore() {
|
|||||||
|
|
||||||
// default layout
|
// default layout
|
||||||
|
|
||||||
const [defaultView, setDefaultView, defaultViewLoaded] = usePersistence(
|
const [defaultView, setDefaultView, defaultViewLoaded] = useUserPersistence(
|
||||||
"exploreDefaultView",
|
"exploreDefaultView",
|
||||||
"summary",
|
"summary",
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import {
|
import { useHashState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||||
useHashState,
|
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||||
usePersistedOverlayState,
|
|
||||||
useSearchEffect,
|
|
||||||
} from "@/hooks/use-overlay-state";
|
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
|
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
|
||||||
import LiveCameraView from "@/views/live/LiveCameraView";
|
import LiveCameraView from "@/views/live/LiveCameraView";
|
||||||
@ -24,7 +21,7 @@ function Live() {
|
|||||||
// selection
|
// selection
|
||||||
|
|
||||||
const [selectedCameraName, setSelectedCameraName] = useHashState();
|
const [selectedCameraName, setSelectedCameraName] = useHashState();
|
||||||
const [cameraGroup, setCameraGroup, loaded, ,] = usePersistedOverlayState(
|
const [cameraGroup, setCameraGroup, loaded] = useUserPersistedOverlayState(
|
||||||
"cameraGroup",
|
"cameraGroup",
|
||||||
"default" as string,
|
"default" as string,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { usePersistence } from "@/hooks/use-persistence";
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||||
import {
|
import {
|
||||||
AllGroupsStreamingSettings,
|
AllGroupsStreamingSettings,
|
||||||
BirdseyeConfig,
|
BirdseyeConfig,
|
||||||
@ -24,6 +24,7 @@ import "react-resizable/css/styles.css";
|
|||||||
import {
|
import {
|
||||||
AudioState,
|
AudioState,
|
||||||
LivePlayerMode,
|
LivePlayerMode,
|
||||||
|
LiveStreamMetadata,
|
||||||
StatsState,
|
StatsState,
|
||||||
VolumeState,
|
VolumeState,
|
||||||
} from "@/types/live";
|
} from "@/types/live";
|
||||||
@ -39,7 +40,7 @@ import { IoClose } from "react-icons/io5";
|
|||||||
import { LuLayoutDashboard, LuPencil } from "react-icons/lu";
|
import { LuLayoutDashboard, LuPencil } from "react-icons/lu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EditGroupDialog } from "@/components/filter/CameraGroupSelector";
|
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 { FaCompress, FaExpand } from "react-icons/fa";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@ -47,7 +48,6 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
|
|
||||||
import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
import LiveContextMenu from "@/components/menu/LiveContextMenu";
|
||||||
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
import { useStreamingSettings } from "@/context/streaming-settings-provider";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -65,6 +65,16 @@ type DraggableGridLayoutProps = {
|
|||||||
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
fullscreen: boolean;
|
fullscreen: boolean;
|
||||||
toggleFullscreen: () => void;
|
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({
|
export default function DraggableGridLayout({
|
||||||
cameras,
|
cameras,
|
||||||
@ -79,6 +89,12 @@ export default function DraggableGridLayout({
|
|||||||
setIsEditMode,
|
setIsEditMode,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
toggleFullscreen,
|
toggleFullscreen,
|
||||||
|
preferredLiveModes,
|
||||||
|
setPreferredLiveModes,
|
||||||
|
resetPreferredLiveMode,
|
||||||
|
isRestreamedStates,
|
||||||
|
supportsAudioOutputStates,
|
||||||
|
streamMetadata,
|
||||||
}: DraggableGridLayoutProps) {
|
}: DraggableGridLayoutProps) {
|
||||||
const { t } = useTranslation(["views/live"]);
|
const { t } = useTranslation(["views/live"]);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@ -86,8 +102,8 @@ export default function DraggableGridLayout({
|
|||||||
|
|
||||||
// preferred live modes per camera
|
// preferred live modes per camera
|
||||||
|
|
||||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
|
||||||
|
|
||||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||||
useStreamingSettings();
|
useStreamingSettings();
|
||||||
@ -98,42 +114,18 @@ export default function DraggableGridLayout({
|
|||||||
}
|
}
|
||||||
}, [allGroupsStreamingSettings, cameraGroup]);
|
}, [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
|
// grid layout
|
||||||
|
|
||||||
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
||||||
|
|
||||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence<
|
const [gridLayout, setGridLayout, isGridLayoutLoaded] = useUserPersistence<
|
||||||
Layout[]
|
Layout[]
|
||||||
>(`${cameraGroup}-draggable-layout`);
|
>(`${cameraGroup}-draggable-layout`);
|
||||||
|
|
||||||
const [group] = usePersistedOverlayState("cameraGroup", "default" as string);
|
const [group] = useUserPersistedOverlayState(
|
||||||
|
"cameraGroup",
|
||||||
|
"default" as string,
|
||||||
|
);
|
||||||
|
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@ -153,6 +145,11 @@ export default function DraggableGridLayout({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditMode(false);
|
setIsEditMode(false);
|
||||||
setEditGroup(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]);
|
}, [cameraGroup, setIsEditMode]);
|
||||||
|
|
||||||
// camera state
|
// camera state
|
||||||
@ -176,104 +173,120 @@ export default function DraggableGridLayout({
|
|||||||
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
||||||
);
|
);
|
||||||
|
|
||||||
const generateLayout = useCallback(() => {
|
const generateLayout = useCallback(
|
||||||
if (!isGridLayoutLoaded) {
|
(baseLayout: Layout[] | undefined) => {
|
||||||
return;
|
if (!isGridLayoutLoaded) {
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let aspectRatio;
|
const cameraNames =
|
||||||
let col;
|
includeBirdseye && birdseyeConfig?.enabled
|
||||||
|
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||||
|
: cameras.map((camera) => camera?.name || "");
|
||||||
|
|
||||||
// Handle "birdseye" camera as a special case
|
const optionsMap: Layout[] = baseLayout
|
||||||
if (cameraName === "birdseye") {
|
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate layout options based on aspect ratio
|
cameraNames.forEach((cameraName, index) => {
|
||||||
const columnsPerPlayer = 4;
|
const existingLayout = optionsMap.find(
|
||||||
let height;
|
(layout) => layout.i === cameraName,
|
||||||
let width;
|
);
|
||||||
|
|
||||||
if (aspectRatio < 1) {
|
// Skip if the camera already exists in the layout
|
||||||
// Portrait
|
if (existingLayout) {
|
||||||
height = 2 * columnsPerPlayer;
|
return;
|
||||||
width = columnsPerPlayer;
|
}
|
||||||
} else if (aspectRatio > 2) {
|
|
||||||
// Wide
|
|
||||||
height = 1 * columnsPerPlayer;
|
|
||||||
width = 2 * columnsPerPlayer;
|
|
||||||
} else {
|
|
||||||
// Landscape
|
|
||||||
height = 1 * columnsPerPlayer;
|
|
||||||
width = columnsPerPlayer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
let aspectRatio;
|
||||||
i: cameraName,
|
let col;
|
||||||
x: col * width,
|
|
||||||
y: 0, // don't set y, grid does automatically
|
|
||||||
w: width,
|
|
||||||
h: height,
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
// Calculate layout options based on aspect ratio
|
||||||
}, [
|
const columnsPerPlayer = 4;
|
||||||
cameras,
|
let height;
|
||||||
isGridLayoutLoaded,
|
let width;
|
||||||
currentGridLayout,
|
|
||||||
includeBirdseye,
|
if (aspectRatio < 1) {
|
||||||
birdseyeConfig,
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (isGridLayoutLoaded) {
|
if (isGridLayoutLoaded) {
|
||||||
if (gridLayout) {
|
if (gridLayout) {
|
||||||
// set current grid layout from loaded
|
// set current grid layout from loaded, possibly adding new cameras
|
||||||
setCurrentGridLayout(gridLayout);
|
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 {
|
} else {
|
||||||
// idb is empty, set it with an initial layout
|
// 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,
|
gridLayout,
|
||||||
currentGridLayout,
|
|
||||||
setGridLayout,
|
setGridLayout,
|
||||||
isGridLayoutLoaded,
|
isGridLayoutLoaded,
|
||||||
generateLayout,
|
generateLayout,
|
||||||
|
cameras,
|
||||||
|
includeBirdseye,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
if (
|
||||||
!isEqual(cameras, currentCameras) ||
|
!isEqual(cameras, currentCameras) ||
|
||||||
includeBirdseye !== currentIncludeBirdseye
|
includeBirdseye !== currentIncludeBirdseye
|
||||||
@ -281,15 +294,17 @@ export default function DraggableGridLayout({
|
|||||||
setCurrentCameras(cameras);
|
setCurrentCameras(cameras);
|
||||||
setCurrentIncludeBirdseye(includeBirdseye);
|
setCurrentIncludeBirdseye(includeBirdseye);
|
||||||
|
|
||||||
// set new grid layout in idb
|
// Regenerate layout based on current layout, adding any new cameras
|
||||||
setGridLayout(generateLayout());
|
const updatedLayout = generateLayout(currentGridLayout);
|
||||||
|
setCurrentGridLayout(updatedLayout);
|
||||||
|
setGridLayout(updatedLayout);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
cameras,
|
cameras,
|
||||||
includeBirdseye,
|
includeBirdseye,
|
||||||
currentCameras,
|
currentCameras,
|
||||||
currentIncludeBirdseye,
|
currentIncludeBirdseye,
|
||||||
setCurrentGridLayout,
|
currentGridLayout,
|
||||||
generateLayout,
|
generateLayout,
|
||||||
setGridLayout,
|
setGridLayout,
|
||||||
isGridLayoutLoaded,
|
isGridLayoutLoaded,
|
||||||
@ -624,6 +639,7 @@ export default function DraggableGridLayout({
|
|||||||
resetPreferredLiveMode(camera.name)
|
resetPreferredLiveMode(camera.name)
|
||||||
}
|
}
|
||||||
config={config}
|
config={config}
|
||||||
|
streamMetadata={streamMetadata}
|
||||||
>
|
>
|
||||||
<LivePlayer
|
<LivePlayer
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
@ -838,6 +854,7 @@ type GridLiveContextMenuProps = {
|
|||||||
unmuteAll: () => void;
|
unmuteAll: () => void;
|
||||||
resetPreferredLiveMode: () => void;
|
resetPreferredLiveMode: () => void;
|
||||||
config?: FrigateConfig;
|
config?: FrigateConfig;
|
||||||
|
streamMetadata?: { [key: string]: LiveStreamMetadata };
|
||||||
};
|
};
|
||||||
|
|
||||||
const GridLiveContextMenu = React.forwardRef<
|
const GridLiveContextMenu = React.forwardRef<
|
||||||
@ -868,6 +885,7 @@ const GridLiveContextMenu = React.forwardRef<
|
|||||||
unmuteAll,
|
unmuteAll,
|
||||||
resetPreferredLiveMode,
|
resetPreferredLiveMode,
|
||||||
config,
|
config,
|
||||||
|
streamMetadata,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@ -899,6 +917,7 @@ const GridLiveContextMenu = React.forwardRef<
|
|||||||
unmuteAll={unmuteAll}
|
unmuteAll={unmuteAll}
|
||||||
resetPreferredLiveMode={resetPreferredLiveMode}
|
resetPreferredLiveMode={resetPreferredLiveMode}
|
||||||
config={config}
|
config={config}
|
||||||
|
streamMetadata={streamMetadata}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</LiveContextMenu>
|
</LiveContextMenu>
|
||||||
|
|||||||
@ -101,7 +101,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { usePersistence } from "@/hooks/use-persistence";
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@ -146,7 +146,7 @@ export default function LiveCameraView({
|
|||||||
|
|
||||||
// supported features
|
// supported features
|
||||||
|
|
||||||
const [streamName, setStreamName] = usePersistence<string>(
|
const [streamName, setStreamName] = useUserPersistence<string>(
|
||||||
`${camera.name}-stream`,
|
`${camera.name}-stream`,
|
||||||
Object.values(camera.live.streams)[0],
|
Object.values(camera.live.streams)[0],
|
||||||
);
|
);
|
||||||
@ -279,7 +279,7 @@ export default function LiveCameraView({
|
|||||||
const [pip, setPip] = useState(false);
|
const [pip, setPip] = useState(false);
|
||||||
const [lowBandwidth, setLowBandwidth] = useState(false);
|
const [lowBandwidth, setLowBandwidth] = useState(false);
|
||||||
|
|
||||||
const [playInBackground, setPlayInBackground] = usePersistence<boolean>(
|
const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
|
||||||
`${camera.name}-background-play`,
|
`${camera.name}-background-play`,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { usePersistence } from "@/hooks/use-persistence";
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||||
import {
|
import {
|
||||||
AllGroupsStreamingSettings,
|
AllGroupsStreamingSettings,
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
@ -78,7 +78,7 @@ export default function LiveDashboardView({
|
|||||||
|
|
||||||
// layout
|
// layout
|
||||||
|
|
||||||
const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">(
|
const [mobileLayout, setMobileLayout] = useUserPersistence<"grid" | "list">(
|
||||||
"live-layout",
|
"live-layout",
|
||||||
isDesktop ? "grid" : "list",
|
isDesktop ? "grid" : "list",
|
||||||
);
|
);
|
||||||
@ -211,8 +211,8 @@ export default function LiveDashboardView({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
|
||||||
|
|
||||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||||
useStreamingSettings();
|
useStreamingSettings();
|
||||||
@ -265,6 +265,7 @@ export default function LiveDashboardView({
|
|||||||
resetPreferredLiveMode,
|
resetPreferredLiveMode,
|
||||||
isRestreamedStates,
|
isRestreamedStates,
|
||||||
supportsAudioOutputStates,
|
supportsAudioOutputStates,
|
||||||
|
streamMetadata,
|
||||||
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
|
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
|
||||||
|
|
||||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||||
@ -650,6 +651,12 @@ export default function LiveDashboardView({
|
|||||||
setIsEditMode={setIsEditMode}
|
setIsEditMode={setIsEditMode}
|
||||||
fullscreen={fullscreen}
|
fullscreen={fullscreen}
|
||||||
toggleFullscreen={toggleFullscreen}
|
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">
|
<TableCell className="text-right">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{user.username !== "admin" &&
|
{user.username !== "admin" && (
|
||||||
user.username !== "viewer" && (
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
size="sm"
|
||||||
size="sm"
|
variant="outline"
|
||||||
variant="outline"
|
className="h-8 px-2"
|
||||||
className="h-8 px-2"
|
onClick={() => {
|
||||||
onClick={() => {
|
setSelectedUser(user.username);
|
||||||
setSelectedUser(user.username);
|
setSelectedUserRole(
|
||||||
setSelectedUserRole(
|
user.role || "viewer",
|
||||||
user.role || "viewer",
|
);
|
||||||
);
|
setShowRoleChange(true);
|
||||||
setShowRoleChange(true);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<LuUserCog className="size-3.5" />
|
||||||
<LuUserCog className="size-3.5" />
|
<span className="ml-1.5 hidden sm:inline-block">
|
||||||
<span className="ml-1.5 hidden sm:inline-block">
|
{t("role.title", { ns: "common" })}
|
||||||
{t("role.title", { ns: "common" })}
|
</span>
|
||||||
</span>
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>
|
||||||
<TooltipContent>
|
<p>{t("users.table.changeRole")}</p>
|
||||||
<p>{t("users.table.changeRole")}</p>
|
</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { Switch } from "@/components/ui/switch";
|
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 { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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`,
|
`${selectedCamera}-feed`,
|
||||||
emptyObject,
|
emptyObject,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useContext, useEffect } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Separator } from "../../components/ui/separator";
|
import { Separator } from "../../components/ui/separator";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { del as delData } from "idb-keyval";
|
import {
|
||||||
import { usePersistence } from "@/hooks/use-persistence";
|
useUserPersistence,
|
||||||
|
deleteUserNamespacedKey,
|
||||||
|
} from "@/hooks/use-user-persistence";
|
||||||
import { isSafari } from "react-device-detect";
|
import { isSafari } from "react-device-detect";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@ -19,6 +21,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "../../components/ui/select";
|
} from "../../components/ui/select";
|
||||||
import { useTranslation } from "react-i18next";
|
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 PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||||
@ -26,13 +29,16 @@ const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
|||||||
export default function UiSettingsView() {
|
export default function UiSettingsView() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const { t } = useTranslation("views/settings");
|
const { t } = useTranslation("views/settings");
|
||||||
|
const { auth } = useContext(AuthContext);
|
||||||
|
const username = auth?.user?.username;
|
||||||
|
|
||||||
const clearStoredLayouts = useCallback(() => {
|
const clearStoredLayouts = useCallback(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(config.camera_groups).forEach(async (value) => {
|
Object.entries(config.camera_groups).forEach(async (value) => {
|
||||||
await delData(`${value[0]}-draggable-layout`)
|
await deleteUserNamespacedKey(`${value[0]}-draggable-layout`, username)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("general.toast.success.clearStoredLayout", {
|
t("general.toast.success.clearStoredLayout", {
|
||||||
@ -56,14 +62,14 @@ export default function UiSettingsView() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [config, t]);
|
}, [config, t, username]);
|
||||||
|
|
||||||
const clearStreamingSettings = useCallback(async () => {
|
const clearStreamingSettings = useCallback(async () => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
await delData(`streaming-settings`)
|
await deleteUserNamespacedKey(`streaming-settings`, username)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(t("general.toast.success.clearStreamingSettings"), {
|
toast.success(t("general.toast.success.clearStreamingSettings"), {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
@ -83,7 +89,7 @@ export default function UiSettingsView() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [config, t]);
|
}, [config, t, username]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("documentTitle.general");
|
document.title = t("documentTitle.general");
|
||||||
@ -91,15 +97,15 @@ export default function UiSettingsView() {
|
|||||||
|
|
||||||
// settings
|
// settings
|
||||||
|
|
||||||
const [autoLive, setAutoLive] = usePersistence("autoLiveView", true);
|
const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true);
|
||||||
const [cameraNames, setCameraName] = usePersistence(
|
const [cameraNames, setCameraName] = useUserPersistence(
|
||||||
"displayCameraNames",
|
"displayCameraNames",
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
|
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||||
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
|
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||||
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
|
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
|
||||||
const [fallbackTimeout, setFallbackTimeout] = usePersistence(
|
const [fallbackTimeout, setFallbackTimeout] = useUserPersistence(
|
||||||
"liveFallbackTimeout",
|
"liveFallbackTimeout",
|
||||||
3,
|
3,
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user