mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 05:24:11 +03:00
Compare commits
5 Commits
6ac79699d6
...
ea068aef17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea068aef17 | ||
|
|
1f9669bbe5 | ||
|
|
9d4aac2b8e | ||
|
|
aa09132dfd | ||
|
|
24766ce427 |
@ -191,6 +191,7 @@ ONVIF
|
|||||||
openai
|
openai
|
||||||
opencv
|
opencv
|
||||||
openvino
|
openvino
|
||||||
|
overfitting
|
||||||
OWASP
|
OWASP
|
||||||
paddleocr
|
paddleocr
|
||||||
paho
|
paho
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.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:
|
||||||
|
|||||||
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 = ""
|
||||||
|
|||||||
@ -168,6 +168,8 @@ Recorded `speech` events will always use a `whisper` model, regardless of the `m
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
Other options are being considered for future versions of Frigate to add transcription options that support external `whisper` Docker containers. A single transcription service could then be shared by Frigate and other applications (for example, Home Assistant Voice), and run on more powerful machines when available.
|
||||||
|
|
||||||
2. Why don't you save live transcription text and use that for `speech` events?
|
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.
|
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.
|
||||||
|
|||||||
@ -69,4 +69,6 @@ Once all images are assigned, training will begin automatically.
|
|||||||
### Improving the Model
|
### Improving the Model
|
||||||
|
|
||||||
- **Problem framing**: Keep classes visually distinct and state-focused (e.g., `open`, `closed`, `unknown`). Avoid combining object identity with state in a single model unless necessary.
|
- **Problem framing**: Keep classes visually distinct and state-focused (e.g., `open`, `closed`, `unknown`). Avoid combining object identity with state in a single model unless necessary.
|
||||||
- **Data collection**: Use the model’s Recent Classifications tab to gather balanced examples across times of day and weather.
|
- **Data collection**: Use the model's Recent Classifications tab to gather balanced examples across times of day and weather.
|
||||||
|
- **When to train**: Focus on cases where the model is entirely incorrect or flips between states when it should not. There's no need to train additional images when the model is already working consistently.
|
||||||
|
- **Selecting training images**: Images scoring below 100% due to new conditions (e.g., first snow of the year, seasonal changes) or variations (e.g., objects temporarily in view, insects at night) are good candidates for training, as they represent scenarios different from the default state. Training these lower-scoring images that differ from existing training data helps prevent overfitting. Avoid training large quantities of images that look very similar, especially if they already score 100% as this can lead to overfitting.
|
||||||
|
|||||||
@ -710,6 +710,44 @@ audio_transcription:
|
|||||||
# List of language codes: https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10
|
# List of language codes: https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10
|
||||||
language: en
|
language: en
|
||||||
|
|
||||||
|
# Optional: Configuration for classification models
|
||||||
|
classification:
|
||||||
|
# Optional: Configuration for bird classification
|
||||||
|
bird:
|
||||||
|
# Optional: Enable bird classification (default: shown below)
|
||||||
|
enabled: False
|
||||||
|
# Optional: Minimum classification score required to be considered a match (default: shown below)
|
||||||
|
threshold: 0.9
|
||||||
|
custom:
|
||||||
|
# Required: name of the classification model
|
||||||
|
model_name:
|
||||||
|
# Optional: Enable running the model (default: shown below)
|
||||||
|
enabled: True
|
||||||
|
# Optional: Name of classification model (default: shown below)
|
||||||
|
name: None
|
||||||
|
# Optional: Classification score threshold to change the state (default: shown below)
|
||||||
|
threshold: 0.8
|
||||||
|
# Optional: Number of classification attempts to save in the recent classifications tab (default: shown below)
|
||||||
|
# NOTE: Defaults to 200 for object classification and 100 for state classification if not specified
|
||||||
|
save_attempts: None
|
||||||
|
# Optional: Object classification configuration
|
||||||
|
object_config:
|
||||||
|
# Required: Object types to classify
|
||||||
|
objects: [dog]
|
||||||
|
# Optional: Type of classification that is applied (default: shown below)
|
||||||
|
classification_type: sub_label
|
||||||
|
# Optional: State classification configuration
|
||||||
|
state_config:
|
||||||
|
# Required: Cameras to run classification on
|
||||||
|
cameras:
|
||||||
|
camera_name:
|
||||||
|
# Required: Crop of image frame on this camera to run classification on
|
||||||
|
crop: [0, 180, 220, 400]
|
||||||
|
# Optional: If classification should be run when motion is detected in the crop (default: shown below)
|
||||||
|
motion: False
|
||||||
|
# Optional: Interval to run classification on in seconds (default: shown below)
|
||||||
|
interval: None
|
||||||
|
|
||||||
# Optional: Restream configuration
|
# Optional: Restream configuration
|
||||||
# Uses https://github.com/AlexxIT/go2rtc (v1.9.10)
|
# Uses https://github.com/AlexxIT/go2rtc (v1.9.10)
|
||||||
# NOTE: The default go2rtc API port (1984) must be used,
|
# NOTE: The default go2rtc API port (1984) must be used,
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -1731,37 +1731,40 @@ def create_trigger_embedding(
|
|||||||
if event.data.get("type") != "object":
|
if event.data.get("type") != "object":
|
||||||
return
|
return
|
||||||
|
|
||||||
if thumbnail := get_event_thumbnail_bytes(event):
|
# Get the thumbnail
|
||||||
cursor = context.db.execute_sql(
|
thumbnail = get_event_thumbnail_bytes(event)
|
||||||
"""
|
|
||||||
SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ?
|
if thumbnail is None:
|
||||||
""",
|
return JSONResponse(
|
||||||
[body.data],
|
content={
|
||||||
|
"success": False,
|
||||||
|
"message": f"Failed to get thumbnail for {body.data} for {body.type} trigger",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
row = cursor.fetchone() if cursor else None
|
# Try to reuse existing embedding from database
|
||||||
|
cursor = context.db.execute_sql(
|
||||||
|
"""
|
||||||
|
SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ?
|
||||||
|
""",
|
||||||
|
[body.data],
|
||||||
|
)
|
||||||
|
|
||||||
if row:
|
row = cursor.fetchone() if cursor else None
|
||||||
query_embedding = row[0]
|
|
||||||
embedding = np.frombuffer(query_embedding, dtype=np.float32)
|
if row:
|
||||||
|
query_embedding = row[0]
|
||||||
|
embedding = np.frombuffer(query_embedding, dtype=np.float32)
|
||||||
else:
|
else:
|
||||||
# Extract valid thumbnail
|
# Generate new embedding
|
||||||
thumbnail = get_event_thumbnail_bytes(event)
|
|
||||||
|
|
||||||
if thumbnail is None:
|
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": False,
|
|
||||||
"message": f"Failed to get thumbnail for {body.data} for {body.type} trigger",
|
|
||||||
},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
embedding = context.generate_image_embedding(
|
embedding = context.generate_image_embedding(
|
||||||
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
|
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
|
||||||
)
|
)
|
||||||
|
|
||||||
if not embedding:
|
if embedding is None or (
|
||||||
|
isinstance(embedding, (list, np.ndarray)) and len(embedding) == 0
|
||||||
|
):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
@ -1896,7 +1899,9 @@ def update_trigger_embedding(
|
|||||||
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
|
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
|
||||||
)
|
)
|
||||||
|
|
||||||
if not embedding:
|
if embedding is None or (
|
||||||
|
isinstance(embedding, (list, np.ndarray)) and len(embedding) == 0
|
||||||
|
):
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"success": False,
|
"success": False,
|
||||||
|
|||||||
@ -105,6 +105,11 @@ class CustomClassificationConfig(FrigateBaseModel):
|
|||||||
threshold: float = Field(
|
threshold: float = Field(
|
||||||
default=0.8, title="Classification score threshold to change the state."
|
default=0.8, title="Classification score threshold to change the state."
|
||||||
)
|
)
|
||||||
|
save_attempts: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
title="Number of classification attempts to save in the recent classifications tab. If not specified, defaults to 200 for object classification and 100 for state classification.",
|
||||||
|
ge=0,
|
||||||
|
)
|
||||||
object_config: CustomClassificationObjectConfig | None = Field(default=None)
|
object_config: CustomClassificationObjectConfig | None = Field(default=None)
|
||||||
state_config: CustomClassificationStateConfig | None = Field(default=None)
|
state_config: CustomClassificationStateConfig | None = Field(default=None)
|
||||||
|
|
||||||
|
|||||||
@ -250,6 +250,11 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
|||||||
if self.interpreter is None:
|
if self.interpreter is None:
|
||||||
# When interpreter is None, always save (score is 0.0, which is < 1.0)
|
# When interpreter is None, always save (score is 0.0, which is < 1.0)
|
||||||
if self._should_save_image(camera, "unknown", 0.0):
|
if self._should_save_image(camera, "unknown", 0.0):
|
||||||
|
save_attempts = (
|
||||||
|
self.model_config.save_attempts
|
||||||
|
if self.model_config.save_attempts is not None
|
||||||
|
else 100
|
||||||
|
)
|
||||||
write_classification_attempt(
|
write_classification_attempt(
|
||||||
self.train_dir,
|
self.train_dir,
|
||||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||||
@ -257,6 +262,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
|||||||
now,
|
now,
|
||||||
"unknown",
|
"unknown",
|
||||||
0.0,
|
0.0,
|
||||||
|
max_files=save_attempts,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -277,6 +283,11 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
|||||||
detected_state = self.labelmap[best_id]
|
detected_state = self.labelmap[best_id]
|
||||||
|
|
||||||
if self._should_save_image(camera, detected_state, score):
|
if self._should_save_image(camera, detected_state, score):
|
||||||
|
save_attempts = (
|
||||||
|
self.model_config.save_attempts
|
||||||
|
if self.model_config.save_attempts is not None
|
||||||
|
else 100
|
||||||
|
)
|
||||||
write_classification_attempt(
|
write_classification_attempt(
|
||||||
self.train_dir,
|
self.train_dir,
|
||||||
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
|
||||||
@ -284,6 +295,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
|
|||||||
now,
|
now,
|
||||||
detected_state,
|
detected_state,
|
||||||
score,
|
score,
|
||||||
|
max_files=save_attempts,
|
||||||
)
|
)
|
||||||
|
|
||||||
if score < self.model_config.threshold:
|
if score < self.model_config.threshold:
|
||||||
@ -482,6 +494,11 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self.interpreter is None:
|
if self.interpreter is None:
|
||||||
|
save_attempts = (
|
||||||
|
self.model_config.save_attempts
|
||||||
|
if self.model_config.save_attempts is not None
|
||||||
|
else 200
|
||||||
|
)
|
||||||
write_classification_attempt(
|
write_classification_attempt(
|
||||||
self.train_dir,
|
self.train_dir,
|
||||||
cv2.cvtColor(crop, cv2.COLOR_RGB2BGR),
|
cv2.cvtColor(crop, cv2.COLOR_RGB2BGR),
|
||||||
@ -489,6 +506,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
|||||||
now,
|
now,
|
||||||
"unknown",
|
"unknown",
|
||||||
0.0,
|
0.0,
|
||||||
|
max_files=save_attempts,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -506,6 +524,11 @@ class CustomObjectClassificationProcessor(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)
|
||||||
|
|
||||||
|
save_attempts = (
|
||||||
|
self.model_config.save_attempts
|
||||||
|
if self.model_config.save_attempts is not None
|
||||||
|
else 200
|
||||||
|
)
|
||||||
write_classification_attempt(
|
write_classification_attempt(
|
||||||
self.train_dir,
|
self.train_dir,
|
||||||
cv2.cvtColor(crop, cv2.COLOR_RGB2BGR),
|
cv2.cvtColor(crop, cv2.COLOR_RGB2BGR),
|
||||||
@ -513,7 +536,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
|
|||||||
now,
|
now,
|
||||||
self.labelmap[best_id],
|
self.labelmap[best_id],
|
||||||
score,
|
score,
|
||||||
max_files=200,
|
max_files=save_attempts,
|
||||||
)
|
)
|
||||||
|
|
||||||
if score < self.model_config.threshold:
|
if score < self.model_config.threshold:
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import shutil
|
|||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from peewee import fn
|
from peewee import SQL, fn
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import RECORD_DIR
|
from frigate.const import RECORD_DIR
|
||||||
@ -44,13 +44,19 @@ class StorageMaintainer(threading.Thread):
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
# calculate MB/hr
|
# calculate MB/hr from last 100 segments
|
||||||
try:
|
try:
|
||||||
bandwidth = round(
|
# Subquery to get last 100 segments, then average their bandwidth
|
||||||
Recordings.select(fn.AVG(bandwidth_equation))
|
last_100 = (
|
||||||
|
Recordings.select(bandwidth_equation.alias("bw"))
|
||||||
.where(Recordings.camera == camera, Recordings.segment_size > 0)
|
.where(Recordings.camera == camera, Recordings.segment_size > 0)
|
||||||
|
.order_by(Recordings.start_time.desc())
|
||||||
.limit(100)
|
.limit(100)
|
||||||
.scalar()
|
.alias("recent")
|
||||||
|
)
|
||||||
|
|
||||||
|
bandwidth = round(
|
||||||
|
Recordings.select(fn.AVG(SQL("bw"))).from_(last_100).scalar()
|
||||||
* 3600,
|
* 3600,
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -330,7 +330,7 @@ def collect_state_classification_examples(
|
|||||||
1. Queries review items from specified cameras
|
1. Queries review items from specified cameras
|
||||||
2. Selects 100 balanced timestamps across the data
|
2. Selects 100 balanced timestamps across the data
|
||||||
3. Extracts keyframes from recordings (cropped to specified regions)
|
3. Extracts keyframes from recordings (cropped to specified regions)
|
||||||
4. Selects 20 most visually distinct images
|
4. Selects 24 most visually distinct images
|
||||||
5. Saves them to the dataset directory
|
5. Saves them to the dataset directory
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -660,7 +660,6 @@ def collect_object_classification_examples(
|
|||||||
Args:
|
Args:
|
||||||
model_name: Name of the classification model
|
model_name: Name of the classification model
|
||||||
label: Object label to collect (e.g., "person", "car")
|
label: Object label to collect (e.g., "person", "car")
|
||||||
cameras: List of camera names to collect examples from
|
|
||||||
"""
|
"""
|
||||||
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
|
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
|
||||||
temp_dir = os.path.join(dataset_dir, "temp")
|
temp_dir = os.path.join(dataset_dir, "temp")
|
||||||
|
|||||||
@ -170,6 +170,10 @@
|
|||||||
"label": "Download snapshot",
|
"label": "Download snapshot",
|
||||||
"aria": "Download snapshot"
|
"aria": "Download snapshot"
|
||||||
},
|
},
|
||||||
|
"downloadCleanSnapshot": {
|
||||||
|
"label": "Download clean snapshot",
|
||||||
|
"aria": "Download clean snapshot"
|
||||||
|
},
|
||||||
"viewTrackingDetails": {
|
"viewTrackingDetails": {
|
||||||
"label": "View tracking details",
|
"label": "View tracking details",
|
||||||
"aria": "Show the tracking details"
|
"aria": "Show the tracking details"
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -108,6 +108,18 @@ export default function SearchResultActions({
|
|||||||
</a>
|
</a>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{searchResult.has_snapshot &&
|
||||||
|
config?.cameras[searchResult.camera].snapshots.clean_copy && (
|
||||||
|
<MenuItem aria-label={t("itemMenu.downloadCleanSnapshot.aria")}>
|
||||||
|
<a
|
||||||
|
className="flex items-center"
|
||||||
|
href={`${baseUrl}api/events/${searchResult.id}/snapshot-clean.webp`}
|
||||||
|
download={`${searchResult.camera}_${searchResult.label}-clean.webp`}
|
||||||
|
>
|
||||||
|
<span>{t("itemMenu.downloadCleanSnapshot.label")}</span>
|
||||||
|
</a>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{searchResult.data.type == "object" && (
|
{searchResult.data.type == "object" && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
aria-label={t("itemMenu.viewTrackingDetails.aria")}
|
||||||
|
|||||||
@ -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(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@ -69,6 +69,20 @@ export default function DetailActionsMenu({
|
|||||||
</a>
|
</a>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{search.has_snapshot &&
|
||||||
|
config?.cameras[search.camera].snapshots.clean_copy && (
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<a
|
||||||
|
className="w-full"
|
||||||
|
href={`${baseUrl}api/events/${search.id}/snapshot-clean.webp`}
|
||||||
|
download={`${search.camera}_${search.label}-clean.webp`}
|
||||||
|
>
|
||||||
|
<div className="flex cursor-pointer items-center gap-2">
|
||||||
|
<span>{t("itemMenu.downloadCleanSnapshot.label")}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
{search.has_clip && (
|
{search.has_clip && (
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@ -498,7 +498,7 @@ export default function SearchDetailDialog({
|
|||||||
|
|
||||||
const views = [...SEARCH_TABS];
|
const views = [...SEARCH_TABS];
|
||||||
|
|
||||||
if (search.data.type != "object" || !search.has_clip) {
|
if (!search.has_clip) {
|
||||||
const index = views.indexOf("tracking_details");
|
const index = views.indexOf("tracking_details");
|
||||||
views.splice(index, 1);
|
views.splice(index, 1);
|
||||||
}
|
}
|
||||||
@ -548,7 +548,7 @@ export default function SearchDetailDialog({
|
|||||||
"relative flex items-center justify-between",
|
"relative flex items-center justify-between",
|
||||||
"w-full",
|
"w-full",
|
||||||
// match dialog's max-width classes
|
// match dialog's max-width classes
|
||||||
"sm:max-w-xl md:max-w-4xl lg:max-w-[70%]",
|
"max-h-[95dvh] max-w-[85%] xl:max-w-[70%]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -594,8 +594,7 @@ export default function SearchDetailDialog({
|
|||||||
ref={isDesktop ? dialogContentRef : undefined}
|
ref={isDesktop ? dialogContentRef : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"scrollbar-container overflow-y-auto",
|
"scrollbar-container overflow-y-auto",
|
||||||
isDesktop &&
|
isDesktop && "max-h-[95dvh] max-w-[85%] xl:max-w-[70%]",
|
||||||
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-[70%]",
|
|
||||||
isMobile && "flex h-full flex-col px-4",
|
isMobile && "flex h-full flex-col px-4",
|
||||||
)}
|
)}
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
|
|||||||
@ -622,7 +622,7 @@ export function TrackingDetails({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
isDesktop && "justify-between overflow-hidden md:basis-2/5",
|
isDesktop && "justify-between overflow-hidden lg:basis-2/5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isDesktop && tabs && (
|
{isDesktop && tabs && (
|
||||||
@ -900,96 +900,99 @@ function LifecycleIconRow({
|
|||||||
<div className="text-md flex items-start break-words text-left">
|
<div className="text-md flex items-start break-words text-left">
|
||||||
{getLifecycleItemDescription(item)}
|
{getLifecycleItemDescription(item)}
|
||||||
</div>
|
</div>
|
||||||
<div className="my-2 ml-2 flex flex-col flex-wrap items-start gap-1.5 text-xs text-secondary-foreground">
|
{/* Only show Score/Ratio/Area for object events, not for audio (heard) or manual API (external) events */}
|
||||||
<div className="flex items-center gap-1.5">
|
{item.class_type !== "heard" && item.class_type !== "external" && (
|
||||||
<span className="text-primary-variant">
|
<div className="my-2 ml-2 flex flex-col flex-wrap items-start gap-1.5 text-xs text-secondary-foreground">
|
||||||
{t("trackingDetails.lifecycleItemDesc.header.score")}
|
<div className="flex items-center gap-1.5">
|
||||||
</span>
|
<span className="text-primary-variant">
|
||||||
<span className="font-medium text-primary">{score}</span>
|
{t("trackingDetails.lifecycleItemDesc.header.score")}
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-primary-variant">
|
|
||||||
{t("trackingDetails.lifecycleItemDesc.header.ratio")}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-primary">{ratio}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-primary-variant">
|
|
||||||
{t("trackingDetails.lifecycleItemDesc.header.area")}{" "}
|
|
||||||
{attributeAreaPx !== undefined &&
|
|
||||||
attributeAreaPct !== undefined && (
|
|
||||||
<span className="text-primary-variant">
|
|
||||||
({getTranslatedLabel(item.data.label)})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{areaPx !== undefined && areaPct !== undefined ? (
|
|
||||||
<span className="font-medium text-primary">
|
|
||||||
{t("information.pixels", { ns: "common", area: areaPx })} ·{" "}
|
|
||||||
{areaPct}%
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
<span className="font-medium text-primary">{score}</span>
|
||||||
<span>N/A</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{attributeAreaPx !== undefined &&
|
|
||||||
attributeAreaPct !== undefined && (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-primary-variant">
|
|
||||||
{t("trackingDetails.lifecycleItemDesc.header.area")} (
|
|
||||||
{getTranslatedLabel(item.data.attribute)})
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-primary">
|
|
||||||
{t("information.pixels", {
|
|
||||||
ns: "common",
|
|
||||||
area: attributeAreaPx,
|
|
||||||
})}{" "}
|
|
||||||
· {attributeAreaPct}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.data?.zones && item.data.zones.length > 0 && (
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
|
||||||
{item.data.zones.map((zone, zidx) => {
|
|
||||||
const color = getZoneColor(zone)?.join(",") ?? "0,0,0";
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
key={`${zone}-${zidx}`}
|
|
||||||
variant="outline"
|
|
||||||
className="inline-flex cursor-pointer items-center gap-2"
|
|
||||||
onClick={(e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelectedZone(zone);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
borderColor: `rgba(${color}, 0.6)`,
|
|
||||||
background: `rgba(${color}, 0.08)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="size-1 rounded-full"
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
backgroundColor: `rgb(${color})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
item.data?.zones_friendly_names?.[zidx] === zone &&
|
|
||||||
"smart-capitalize",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.data?.zones_friendly_names?.[zidx]}
|
|
||||||
</span>
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex items-center gap-1.5">
|
||||||
</div>
|
<span className="text-primary-variant">
|
||||||
|
{t("trackingDetails.lifecycleItemDesc.header.ratio")}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-primary">{ratio}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-primary-variant">
|
||||||
|
{t("trackingDetails.lifecycleItemDesc.header.area")}{" "}
|
||||||
|
{attributeAreaPx !== undefined &&
|
||||||
|
attributeAreaPct !== undefined && (
|
||||||
|
<span className="text-primary-variant">
|
||||||
|
({getTranslatedLabel(item.data.label)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{areaPx !== undefined && areaPct !== undefined ? (
|
||||||
|
<span className="font-medium text-primary">
|
||||||
|
{t("information.pixels", { ns: "common", area: areaPx })}{" "}
|
||||||
|
· {areaPct}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>N/A</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{attributeAreaPx !== undefined &&
|
||||||
|
attributeAreaPct !== undefined && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-primary-variant">
|
||||||
|
{t("trackingDetails.lifecycleItemDesc.header.area")} (
|
||||||
|
{getTranslatedLabel(item.data.attribute)})
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-primary">
|
||||||
|
{t("information.pixels", {
|
||||||
|
ns: "common",
|
||||||
|
area: attributeAreaPx,
|
||||||
|
})}{" "}
|
||||||
|
· {attributeAreaPct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.data?.zones && item.data.zones.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
{item.data.zones.map((zone, zidx) => {
|
||||||
|
const color = getZoneColor(zone)?.join(",") ?? "0,0,0";
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={`${zone}-${zidx}`}
|
||||||
|
variant="outline"
|
||||||
|
className="inline-flex cursor-pointer items-center gap-2"
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedZone(zone);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
borderColor: `rgba(${color}, 0.6)`,
|
||||||
|
background: `rgba(${color}, 0.08)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="size-1 rounded-full"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
backgroundColor: `rgb(${color})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
item.data?.zones_friendly_names?.[zidx] === zone &&
|
||||||
|
"smart-capitalize",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.data?.zones_friendly_names?.[zidx]}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 flex-shrink-0 px-1 text-right text-xs text-primary-variant">
|
<div className="ml-3 flex-shrink-0 px-1 text-right text-xs text-primary-variant">
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,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,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -305,6 +305,7 @@ export type CustomClassificationModelConfig = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
|
save_attempts?: number;
|
||||||
object_config?: {
|
object_config?: {
|
||||||
objects: string[];
|
objects: string[];
|
||||||
classification_type: string;
|
classification_type: string;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { usePersistence } from "@/hooks/use-persistence";
|
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||||
import {
|
import {
|
||||||
AllGroupsStreamingSettings,
|
AllGroupsStreamingSettings,
|
||||||
BirdseyeConfig,
|
BirdseyeConfig,
|
||||||
@ -40,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,
|
||||||
@ -102,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();
|
||||||
@ -118,11 +118,14 @@ export default function DraggableGridLayout({
|
|||||||
|
|
||||||
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) {
|
||||||
@ -142,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
|
||||||
@ -165,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
|
||||||
@ -270,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,
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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