Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
0c593f37de
Merge f01dd335c2 into 1a75251ffb 2025-11-29 16:15:05 +01:00
47 changed files with 471 additions and 1077 deletions

View File

@ -191,7 +191,6 @@ ONVIF
openai
opencv
openvino
overfitting
OWASP
paddleocr
paho

View File

@ -15,7 +15,7 @@ concurrency:
cancel-in-progress: true
env:
PYTHON_VERSION: 3.11
PYTHON_VERSION: 3.9
jobs:
amd64_build:

View File

@ -1,31 +1,28 @@
<p align="center">
<img align="center" alt="logo" src="docs/static/img/branding/frigate.png">
<img align="center" alt="logo" src="docs/static/img/frigate.png">
</p>
# Frigate NVR™ - 一个具有实时目标检测的本地 NVR
# Frigate - 一个具有实时目标检测的本地NVR
[English](https://github.com/blakeblackshear/frigate) | \[简体中文\]
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[English](https://github.com/blakeblackshear/frigate) | \[简体中文\]
<a href="https://hosted.weblate.org/engage/frigate-nvr/-/zh_Hans/">
<img src="https://hosted.weblate.org/widget/frigate-nvr/-/zh_Hans/svg-badge.svg" alt="翻译状态" />
</a>
一个完整的本地网络视频录像机NVR专为[Home Assistant](https://www.home-assistant.io)设计,具备 AI 目标/物体检测功能。使用 OpenCV TensorFlow 在本地为 IP 摄像头执行实时物体检测。
一个完整的本地网络视频录像机NVR专为[Home Assistant](https://www.home-assistant.io)设计具备AI物体检测功能。使用OpenCV和TensorFlow在本地为IP摄像头执行实时物体检测。
强烈推荐使用 GPU 或者 AI 加速器(例如[Google Coral 加速器](https://coral.ai/products/) 或者 [Hailo](https://hailo.ai/)等)。它们的运行效率远远高于现在的顶级 CPU并且功耗也极低。
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与 Home Assistant 紧密集成
- 设计上通过仅在必要时和必要地点寻找目标,最大限度地减少资源使用并最大化性能
强烈推荐使用GPU或者AI加速器例如[Google Coral加速器](https://coral.ai/products/) 或者 [Hailo](https://hailo.ai/)。它们的性能甚至超过目前的顶级CPU并且可以以极低的耗电实现更优的性能。
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与Home Assistant紧密集成
- 设计上通过仅在必要时和必要地点寻找物体,最大限度地减少资源使用并最大化性能
- 大量利用多进程处理,强调实时性而非处理每一帧
- 使用非常低开销的画面变动检测(也叫运动检测)来确定运行目标检测的位置
- 使用 TensorFlow 进行目标检测,并运行在单独的进程中以达到最大 FPS
- 通过 MQTT 进行通信,便于集成到其他系统中
- 使用非常低开销的运动检测来确定运行物体检测的位置
- 使用TensorFlow进行物体检测运行在单独的进程中以达到最大FPS
- 通过MQTT进行通信便于集成到其他系统中
- 根据检测到的物体设置保留时间进行视频录制
- 24/7 全天候录制
- 通过 RTSP 重新流传输以减少摄像头的连接数
- 支持 WebRTC MSE实现低延迟的实时观看
- 24/7全天候录制
- 通过RTSP重新流传输以减少摄像头的连接数
- 支持WebRTC和MSE实现低延迟的实时观看
## 社区中文翻译文档
@ -35,55 +32,39 @@
如果您想通过捐赠支持开发,请使用 [Github Sponsors](https://github.com/sponsors/blakeblackshear)。
## 协议
本项目采用 **MIT 许可证**授权。
**代码部分**:本代码库中的源代码、配置文件和文档均遵循 [MIT 许可证](LICENSE)。您可以自由使用、修改和分发这些代码,但必须保留原始版权声明。
**商标部分**“Frigate”名称、“Frigate NVR”品牌以及 Frigate 的 Logo 为 **Frigate LLC 的商标****不在** MIT 许可证覆盖范围内。
有关品牌资产的规范使用详情,请参阅我们的[《商标政策》](TRADEMARK.md)。
## 截图
### 实时监控面板
<div>
<img width="800" alt="实时监控面板" src="https://github.com/blakeblackshear/frigate/assets/569905/5e713cb9-9db5-41dc-947a-6937c3bc376e">
</div>
### 简单的核查工作流程
<div>
<img width="800" alt="简单的审查工作流程" src="https://github.com/blakeblackshear/frigate/assets/569905/6fed96e8-3b18-40e5-9ddc-31e6f3c9f2ff">
</div>
### 多摄像头可按时间轴查看
<div>
<img width="800" alt="多摄像头可按时间轴查看" src="https://github.com/blakeblackshear/frigate/assets/569905/d6788a15-0eeb-4427-a8d4-80b93cae3d74">
</div>
### 内置遮罩和区域编辑器
<div>
<img width="800" alt="内置遮罩和区域编辑器" src="https://github.com/blakeblackshear/frigate/assets/569905/d7885fc3-bfe6-452f-b7d0-d957cb3e31f5">
</div>
## 翻译
## 翻译
我们使用 [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) 平台提供翻译支持,欢迎参与进来一起完善。
## 非官方中文讨论社区
欢迎加入中文讨论 QQ 群:[1043861059](https://qm.qq.com/q/7vQKsTmSz)
## 非官方中文讨论社区
欢迎加入中文讨论QQ群[1043861059](https://qm.qq.com/q/7vQKsTmSz)
Bilibilihttps://space.bilibili.com/3546894915602564
## 中文社区赞助商
## 中文社区赞助商
[![EdgeOne](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)](https://edgeone.ai/zh?from=github)
本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助
---
**Copyright © 2025 Frigate LLC.**

View File

@ -15,7 +15,7 @@ ARG AMDGPU
RUN apt update -qq && \
apt install -y wget gpg && \
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1.1/ubuntu/jammy/amdgpu-install_7.1.1.70101-1_all.deb && \
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1/ubuntu/jammy/amdgpu-install_7.1.70100-1_all.deb && \
apt install -y ./rocm.deb && \
apt update && \
apt install -qq -y rocm

View File

@ -2,7 +2,7 @@ variable "AMDGPU" {
default = "gfx900"
}
variable "ROCM" {
default = "7.1.1"
default = "7.1.0"
}
variable "HSA_OVERRIDE_GFX_VERSION" {
default = ""

View File

@ -157,21 +157,3 @@ Only one `speech` event may be transcribed at a time. Frigate does not automatic
:::
Recorded `speech` events will always use a `whisper` model, regardless of the `model_size` config setting. Without a supported Nvidia GPU, generating transcriptions for longer `speech` events may take a fair amount of time, so be patient.
#### FAQ
1. Why doesn't Frigate automatically transcribe all `speech` events?
Frigate does not implement a queue mechanism for speech transcription, and adding one is not trivial. A proper queue would need backpressure, prioritization, memory/disk buffering, retry logic, crash recovery, and safeguards to prevent unbounded growth when events outpace processing. Thats 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 thats 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?
Theres 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. Thats why Frigate treats transcription as an **explicit, user-initiated action** rather than an automatic side-effect of every `speech` event.

View File

@ -69,6 +69,4 @@ Once all images are assigned, training will begin automatically.
### 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.
- **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.
- **Data collection**: Use the models Recent Classifications tab to gather balanced examples across times of day and weather.

View File

@ -710,44 +710,6 @@ audio_transcription:
# List of language codes: https://github.com/openai/whisper/blob/main/whisper/tokenizer.py#L10
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
# Uses https://github.com/AlexxIT/go2rtc (v1.9.10)
# NOTE: The default go2rtc API port (1984) must be used,

View File

@ -1,18 +1,13 @@
.alert {
padding: 12px;
background: #fff8e6;
border-bottom: 1px solid #ffd166;
text-align: center;
font-size: 15px;
}
[data-theme="dark"] .alert {
background: #3b2f0b;
border-bottom: 1px solid #665c22;
}
.alert a {
color: #1890ff;
font-weight: 500;
margin-left: 6px;
}
padding: 12px;
background: #fff8e6;
border-bottom: 1px solid #ffd166;
text-align: center;
font-size: 15px;
}
.alert a {
color: #1890ff;
font-weight: 500;
margin-left: 6px;
}

View File

@ -1731,40 +1731,37 @@ def create_trigger_embedding(
if event.data.get("type") != "object":
return
# Get the thumbnail
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,
if thumbnail := get_event_thumbnail_bytes(event):
cursor = context.db.execute_sql(
"""
SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ?
""",
[body.data],
)
# Try to reuse existing embedding from database
cursor = context.db.execute_sql(
"""
SELECT thumbnail_embedding FROM vec_thumbnails WHERE id = ?
""",
[body.data],
)
row = cursor.fetchone() if cursor else None
row = cursor.fetchone() if cursor else None
if row:
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:
# Generate new embedding
# Extract valid thumbnail
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(
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
)
if embedding is None or (
isinstance(embedding, (list, np.ndarray)) and len(embedding) == 0
):
if not embedding:
return JSONResponse(
content={
"success": False,
@ -1899,9 +1896,7 @@ def update_trigger_embedding(
body.data, (base64.b64encode(thumbnail).decode("ASCII"))
)
if embedding is None or (
isinstance(embedding, (list, np.ndarray)) and len(embedding) == 0
):
if not embedding:
return JSONResponse(
content={
"success": False,

View File

@ -105,11 +105,6 @@ class CustomClassificationConfig(FrigateBaseModel):
threshold: float = Field(
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)
state_config: CustomClassificationStateConfig | None = Field(default=None)

View File

@ -99,42 +99,6 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
if self.inference_speed:
self.inference_speed.update(duration)
def _should_save_image(
self, camera: str, detected_state: str, score: float = 1.0
) -> bool:
"""
Determine if we should save the image for training.
Save when:
- State is changing or being verified (regardless of score)
- Score is less than 100% (even if state matches, useful for training)
Don't save when:
- State is stable (matches current_state) AND score is 100%
"""
if camera not in self.state_history:
# First detection for this camera, save it
return True
verification = self.state_history[camera]
current_state = verification.get("current_state")
pending_state = verification.get("pending_state")
# Save if there's a pending state change being verified
if pending_state is not None:
return True
# Save if the detected state differs from the current verified state
# (state is changing)
if current_state is not None and detected_state != current_state:
return True
# If score is less than 100%, save even if state matches
# (useful for training to improve confidence)
if score < 1.0:
return True
# Don't save if state is stable (detected_state == current_state) AND score is 100%
return False
def verify_state_change(self, camera: str, detected_state: str) -> str | None:
"""
Verify state change requires 3 consecutive identical states before publishing.
@ -248,22 +212,14 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
return
if self.interpreter is None:
# When interpreter is None, always save (score is 0.0, which is < 1.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(
self.train_dir,
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
"none-none",
now,
"unknown",
0.0,
max_files=save_attempts,
)
write_classification_attempt(
self.train_dir,
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
"none-none",
now,
"unknown",
0.0,
)
return
input = np.expand_dims(resized_frame, axis=0)
@ -280,23 +236,14 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
score = round(probs[best_id], 2)
self.__update_metrics(datetime.datetime.now().timestamp() - now)
detected_state = self.labelmap[best_id]
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(
self.train_dir,
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
"none-none",
now,
detected_state,
score,
max_files=save_attempts,
)
write_classification_attempt(
self.train_dir,
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
"none-none",
now,
self.labelmap[best_id],
score,
)
if score < self.model_config.threshold:
logger.debug(
@ -304,6 +251,7 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
)
return
detected_state = self.labelmap[best_id]
verified_state = self.verify_state_change(camera, detected_state)
if verified_state is not None:
@ -494,11 +442,6 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
return
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(
self.train_dir,
cv2.cvtColor(crop, cv2.COLOR_RGB2BGR),
@ -506,7 +449,6 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
now,
"unknown",
0.0,
max_files=save_attempts,
)
return
@ -524,11 +466,6 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
score = round(probs[best_id], 2)
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(
self.train_dir,
cv2.cvtColor(crop, cv2.COLOR_RGB2BGR),
@ -536,7 +473,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi):
now,
self.labelmap[best_id],
score,
max_files=save_attempts,
max_files=200,
)
if score < self.model_config.threshold:

View File

@ -190,11 +190,7 @@ class OnvifController:
ptz: ONVIFService = await onvif.create_ptz_service()
self.cams[camera_name]["ptz"] = ptz
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
imaging: ONVIFService = await onvif.create_imaging_service()
self.cams[camera_name]["imaging"] = imaging
try:
video_sources = await media.GetVideoSources()
@ -385,10 +381,7 @@ class OnvifController:
f"Disabling autotracking zooming for {camera_name}: Absolute zoom not supported. Exception: {e}"
)
if (
self.cams[camera_name]["video_source_token"] is not None
and imaging is not None
):
if self.cams[camera_name]["video_source_token"] is not None:
try:
imaging_capabilities = await imaging.GetImagingSettings(
{"VideoSourceToken": self.cams[camera_name]["video_source_token"]}
@ -428,7 +421,6 @@ class OnvifController:
if (
"focus" in self.cams[camera_name]["features"]
and self.cams[camera_name]["video_source_token"]
and self.cams[camera_name]["imaging"] is not None
):
try:
stop_request = self.cams[camera_name]["imaging"].create_type("Stop")
@ -656,7 +648,6 @@ class OnvifController:
if (
"focus" not in self.cams[camera_name]["features"]
or not self.cams[camera_name]["video_source_token"]
or self.cams[camera_name]["imaging"] is None
):
logger.error(f"{camera_name} does not support ONVIF continuous focus.")
return

View File

@ -5,7 +5,7 @@ import shutil
import threading
from pathlib import Path
from peewee import SQL, fn
from peewee import fn
from frigate.config import FrigateConfig
from frigate.const import RECORD_DIR
@ -44,19 +44,13 @@ class StorageMaintainer(threading.Thread):
)
}
# calculate MB/hr from last 100 segments
# calculate MB/hr
try:
# Subquery to get last 100 segments, then average their bandwidth
last_100 = (
Recordings.select(bandwidth_equation.alias("bw"))
.where(Recordings.camera == camera, Recordings.segment_size > 0)
.order_by(Recordings.start_time.desc())
.limit(100)
.alias("recent")
)
bandwidth = round(
Recordings.select(fn.AVG(SQL("bw"))).from_(last_100).scalar()
Recordings.select(fn.AVG(bandwidth_equation))
.where(Recordings.camera == camera, Recordings.segment_size > 0)
.limit(100)
.scalar()
* 3600,
2,
)

View File

@ -330,7 +330,7 @@ def collect_state_classification_examples(
1. Queries review items from specified cameras
2. Selects 100 balanced timestamps across the data
3. Extracts keyframes from recordings (cropped to specified regions)
4. Selects 24 most visually distinct images
4. Selects 20 most visually distinct images
5. Saves them to the dataset directory
Args:
@ -660,6 +660,7 @@ def collect_object_classification_examples(
Args:
model_name: Name of the classification model
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")
temp_dir = os.path.join(dataset_dir, "temp")

View File

@ -124,50 +124,45 @@ def capture_frames(
config_subscriber.check_for_updates()
return config.enabled
try:
while not stop_event.is_set():
if not get_enabled_state():
logger.debug(f"Stopping capture thread for disabled {config.name}")
while not stop_event.is_set():
if not get_enabled_state():
logger.debug(f"Stopping capture thread for disabled {config.name}")
break
fps.value = frame_rate.eps()
skipped_fps.value = skipped_eps.eps()
current_frame.value = datetime.now().timestamp()
frame_name = f"{config.name}_frame{frame_index}"
frame_buffer = frame_manager.write(frame_name)
try:
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
except Exception:
# shutdown has been initiated
if stop_event.is_set():
break
fps.value = frame_rate.eps()
skipped_fps.value = skipped_eps.eps()
current_frame.value = datetime.now().timestamp()
frame_name = f"{config.name}_frame{frame_index}"
frame_buffer = frame_manager.write(frame_name)
try:
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
except Exception:
# shutdown has been initiated
if stop_event.is_set():
break
logger.error(f"{config.name}: Unable to read frames from ffmpeg process.")
if ffmpeg_process.poll() is not None:
logger.error(
f"{config.name}: Unable to read frames from ffmpeg process."
f"{config.name}: ffmpeg process is not running. exiting capture thread..."
)
break
if ffmpeg_process.poll() is not None:
logger.error(
f"{config.name}: ffmpeg process is not running. exiting capture thread..."
)
break
continue
continue
frame_rate.update()
frame_rate.update()
# 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()
# 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()
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
class CameraWatchdog(threading.Thread):
@ -239,16 +234,6 @@ class CameraWatchdog(threading.Thread):
else:
self.ffmpeg_detect_process.wait()
# Wait for old capture thread to fully exit before starting a new one
if self.capture_thread is not None and self.capture_thread.is_alive():
self.logger.info("Waiting for capture thread to exit...")
self.capture_thread.join(timeout=5)
if self.capture_thread.is_alive():
self.logger.warning(
f"Capture thread for {self.config.name} did not exit in time"
)
self.logger.error(
"The following ffmpeg logs include the last 100 lines prior to exit."
)

View File

@ -170,10 +170,6 @@
"label": "Download snapshot",
"aria": "Download snapshot"
},
"downloadCleanSnapshot": {
"label": "Download clean snapshot",
"aria": "Download clean snapshot"
},
"viewTrackingDetails": {
"label": "View tracking details",
"aria": "Show the tracking details"

View File

@ -5,7 +5,7 @@ import { Button } from "../ui/button";
import { LuSettings } from "react-icons/lu";
import { useCallback, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage";
import { useTranslation } from "react-i18next";
@ -24,7 +24,7 @@ export default function DebugCameraImage({
}: DebugCameraImageProps) {
const { t } = useTranslation(["components/camera"]);
const [showSettings, setShowSettings] = useState(false);
const [options, setOptions] = useUserPersistence<Options>(
const [options, setOptions] = usePersistence<Options>(
`${cameraConfig?.name}-feed`,
emptyObject,
);

View File

@ -13,7 +13,7 @@ import { baseUrl } from "@/api/baseUrl";
import { VideoPreview } from "../preview/ScrubbablePreview";
import { useApiHost } from "@/api";
import { isDesktop, isSafari } from "react-device-detect";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import { Skeleton } from "../ui/skeleton";
import { Button } from "../ui/button";
import { FaCircleCheck } from "react-icons/fa6";
@ -112,7 +112,7 @@ export function AnimatedEventCard({
// image behavior
const [alertVideos, _, alertVideosLoaded] = useUserPersistence(
const [alertVideos, _, alertVideosLoaded] = usePersistence(
"alertVideos",
true,
);

View File

@ -37,7 +37,7 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { LuPlus, LuX } from "react-icons/lu";
import { toast } from "sonner";
import useSWR, { mutate } from "swr";
import useSWR from "swr";
import { z } from "zod";
type ClassificationModelEditDialogProps = {
@ -240,61 +240,15 @@ export default function ClassificationModelEditDialog({
position: "top-center",
});
} else {
const stateData = data as StateFormData;
const newClasses = stateData.classes.filter(
(c) => c.trim().length > 0,
);
const oldClasses = dataset?.categories
? Object.keys(dataset.categories).filter((key) => key !== "none")
: [];
// State model - update classes
// Note: For state models, updating classes requires renaming categories
// which is handled through the dataset API, not the config API
// We'll need to implement this by calling the rename endpoint for each class
// For now, we just show a message that this requires retraining
const renameMap = new Map<string, string>();
const maxLength = Math.max(oldClasses.length, newClasses.length);
for (let i = 0; i < maxLength; i++) {
const oldClass = oldClasses[i];
const newClass = newClasses[i];
if (oldClass && newClass && oldClass !== newClass) {
renameMap.set(oldClass, newClass);
}
}
const renamePromises = Array.from(renameMap.entries()).map(
async ([oldName, newName]) => {
try {
await axios.put(
`/classification/${model.name}/dataset/${oldName}/rename`,
{
new_category: newName,
},
);
} catch (err) {
const error = err as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
throw new Error(
`Failed to rename ${oldName} to ${newName}: ${errorMessage}`,
);
}
},
);
if (renamePromises.length > 0) {
await Promise.all(renamePromises);
await mutate(`classification/${model.name}/dataset`);
toast.success(t("toast.success.updatedModel"), {
position: "top-center",
});
} else {
toast.info(t("edit.stateClassesInfo"), {
position: "top-center",
});
}
toast.info(t("edit.stateClassesInfo"), {
position: "top-center",
});
}
onSuccess();
@ -302,10 +256,8 @@ export default function ClassificationModelEditDialog({
} catch (err) {
const error = err as {
response?: { data?: { message?: string; detail?: string } };
message?: string;
};
const errorMessage =
error.message ||
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
@ -316,7 +268,7 @@ export default function ClassificationModelEditDialog({
setIsSaving(false);
}
},
[isObjectModel, model, dataset, t, onSuccess, onClose],
[isObjectModel, model, t, onSuccess, onClose],
);
const handleCancel = useCallback(() => {

View File

@ -7,6 +7,7 @@ import {
import { isDesktop, isMobile } from "react-device-detect";
import useSWR from "swr";
import { MdHome } from "react-icons/md";
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
import { Button, buttonVariants } from "../ui/button";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
@ -56,7 +57,7 @@ import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import ActivityIndicator from "../indicators/activity-indicator";
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
import * as LuIcons from "react-icons/lu";
@ -78,7 +79,6 @@ import { Trans, useTranslation } from "react-i18next";
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
type CameraGroupSelectorProps = {
className?: string;
@ -109,9 +109,9 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
[timeoutId],
);
// groups - use user-namespaced key for persistence to avoid cross-user conflicts
// groups
const [group, setGroup, , deleteGroup] = useUserPersistedOverlayState(
const [group, setGroup, , deleteGroup] = usePersistedOverlayState(
"cameraGroup",
"default" as string,
);
@ -276,7 +276,7 @@ function NewGroupDialog({
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
const [isLoading, setIsLoading] = useState(false);
const [, , , deleteGridLayout] = useUserPersistence(
const [, , , deleteGridLayout] = usePersistence(
`${activeGroup}-draggable-layout`,
);

View File

@ -37,7 +37,7 @@ import {
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import { SaveSearchDialog } from "./SaveSearchDialog";
import { DeleteSearchDialog } from "./DeleteSearchDialog";
import {
@ -128,8 +128,9 @@ export default function InputWithTags({
// TODO: search history from browser storage
const [searchHistory, setSearchHistory, searchHistoryLoaded] =
useUserPersistence<SavedSearchQuery[]>("frigate-search-history");
const [searchHistory, setSearchHistory, searchHistoryLoaded] = usePersistence<
SavedSearchQuery[]
>("frigate-search-history");
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);

View File

@ -48,7 +48,6 @@ import { useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
import { LiveStreamMetadata } from "@/types/live";
type LiveContextMenuProps = {
className?: string;
@ -69,7 +68,6 @@ type LiveContextMenuProps = {
resetPreferredLiveMode: () => void;
config?: FrigateConfig;
children?: ReactNode;
streamMetadata?: { [key: string]: LiveStreamMetadata };
};
export default function LiveContextMenu({
className,
@ -90,7 +88,6 @@ export default function LiveContextMenu({
resetPreferredLiveMode,
config,
children,
streamMetadata,
}: LiveContextMenuProps) {
const { t } = useTranslation("views/live");
const [showSettings, setShowSettings] = useState(false);
@ -561,7 +558,6 @@ export default function LiveContextMenu({
setGroupStreamingSettings={setGroupStreamingSettings}
setIsDialogOpen={setShowSettings}
onSave={onSave}
streamMetadata={streamMetadata}
/>
</Dialog>
</div>

View File

@ -108,18 +108,6 @@ export default function SearchResultActions({
</a>
</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" && (
<MenuItem
aria-label={t("itemMenu.viewTrackingDetails.aria")}

View File

@ -5,7 +5,7 @@ import { FaCircle } from "react-icons/fa";
import { getUTCOffset } from "@/utils/dateUtil";
import { type DayButtonProps, TZDate } from "react-day-picker";
import { LAST_24_HOURS_KEY } from "@/types/filter";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import { cn } from "@/lib/utils";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
@ -27,7 +27,7 @@ export default function ReviewActivityCalendar({
}: ReviewActivityCalendarProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const timezone = useTimezone(config);
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
const disabledDates = useMemo(() => {
const tomorrow = new Date();
@ -176,7 +176,7 @@ export function TimezoneAwareCalendar({
selectedDay,
onSelect,
}: TimezoneAwareCalendarProps) {
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
const timezoneOffset = useMemo(
() =>

View File

@ -69,20 +69,6 @@ export default function DetailActionsMenu({
</a>
</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 && (
<DropdownMenuItem>
<a

View File

@ -498,7 +498,7 @@ export default function SearchDetailDialog({
const views = [...SEARCH_TABS];
if (!search.has_clip) {
if (search.data.type != "object" || !search.has_clip) {
const index = views.indexOf("tracking_details");
views.splice(index, 1);
}
@ -548,7 +548,7 @@ export default function SearchDetailDialog({
"relative flex items-center justify-between",
"w-full",
// match dialog's max-width classes
"max-h-[95dvh] max-w-[85%] xl:max-w-[70%]",
"sm:max-w-xl md:max-w-4xl lg:max-w-[70%]",
)}
>
<Tooltip>
@ -594,7 +594,8 @@ export default function SearchDetailDialog({
ref={isDesktop ? dialogContentRef : undefined}
className={cn(
"scrollbar-container overflow-y-auto",
isDesktop && "max-h-[95dvh] max-w-[85%] xl:max-w-[70%]",
isDesktop &&
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-[70%]",
isMobile && "flex h-full flex-col px-4",
)}
onEscapeKeyDown={(event) => {

View File

@ -622,7 +622,7 @@ export function TrackingDetails({
<div
className={cn(
isDesktop && "justify-between overflow-hidden lg:basis-2/5",
isDesktop && "justify-between overflow-hidden md:basis-2/5",
)}
>
{isDesktop && tabs && (
@ -900,99 +900,96 @@ function LifecycleIconRow({
<div className="text-md flex items-start break-words text-left">
{getLifecycleItemDescription(item)}
</div>
{/* Only show Score/Ratio/Area for object events, not for audio (heard) or manual API (external) events */}
{item.class_type !== "heard" && item.class_type !== "external" && (
<div className="my-2 ml-2 flex flex-col flex-wrap items-start gap-1.5 text-xs text-secondary-foreground">
<div className="flex items-center gap-1.5">
<span className="text-primary-variant">
{t("trackingDetails.lifecycleItemDesc.header.score")}
</span>
<span className="font-medium text-primary">{score}</span>
</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>N/A</span>
)}
</div>
{attributeAreaPx !== undefined &&
attributeAreaPct !== undefined && (
<div className="flex items-center gap-1.5">
<div className="my-2 ml-2 flex flex-col flex-wrap items-start gap-1.5 text-xs text-secondary-foreground">
<div className="flex items-center gap-1.5">
<span className="text-primary-variant">
{t("trackingDetails.lifecycleItemDesc.header.score")}
</span>
<span className="font-medium text-primary">{score}</span>
</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">
{t("trackingDetails.lifecycleItemDesc.header.area")} (
{getTranslatedLabel(item.data.attribute)})
({getTranslatedLabel(item.data.label)})
</span>
<span className="font-medium text-primary">
{t("information.pixels", {
ns: "common",
area: attributeAreaPx,
})}{" "}
· {attributeAreaPct}%
</span>
</div>
)}
)}
</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>
)}
{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})`,
{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={cn(
item.data?.zones_friendly_names?.[zidx] === zone &&
"smart-capitalize",
)}
>
{item.data?.zones_friendly_names?.[zidx]}
</span>
</Badge>
);
})}
</div>
)}
<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 className="ml-3 flex-shrink-0 px-1 text-right text-xs text-primary-variant">

View File

@ -15,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { AxiosResponse } from "axios";
import { toast } from "sonner";
import { useOverlayState } from "@/hooks/use-overlay-state";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import { cn } from "@/lib/utils";
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
import { useTranslation } from "react-i18next";
@ -210,9 +210,9 @@ export default function HlsVideoPlayer({
const [tallCamera, setTallCamera] = useState(false);
const [isPlaying, setIsPlaying] = useState(true);
const [muted, setMuted] = useUserPersistence("hlsPlayerMuted", true);
const [muted, setMuted] = usePersistence("hlsPlayerMuted", true);
const [volume, setVolume] = useOverlayState("playerVolume", 1.0);
const [defaultPlaybackRate] = useUserPersistence("playbackRate", 1);
const [defaultPlaybackRate] = usePersistence("playbackRate", 1);
const [playbackRate, setPlaybackRate] = useOverlayState(
"playbackRate",
defaultPlaybackRate ?? 1,

View File

@ -1,5 +1,5 @@
import { baseUrl } from "@/api/baseUrl";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import {
LivePlayerError,
PlayerStatsType,
@ -72,10 +72,7 @@ function MSEPlayer({
const [errorCount, setErrorCount] = useState<number>(0);
const totalBytesLoaded = useRef(0);
const [fallbackTimeout] = useUserPersistence<number>(
"liveFallbackTimeout",
3,
);
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3);
const videoRef = useRef<HTMLVideoElement>(null);
const wsRef = useRef<WebSocket | null>(null);

View File

@ -38,7 +38,6 @@ import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
type CameraStreamingDialogProps = {
camera: string;
groupStreamingSettings: GroupStreamingSettings;
streamMetadata?: { [key: string]: LiveStreamMetadata };
setGroupStreamingSettings: React.Dispatch<
React.SetStateAction<GroupStreamingSettings>
>;
@ -49,7 +48,6 @@ type CameraStreamingDialogProps = {
export function CameraStreamingDialog({
camera,
groupStreamingSettings,
streamMetadata,
setGroupStreamingSettings,
setIsDialogOpen,
onSave,
@ -78,7 +76,12 @@ export function CameraStreamingDialog({
[config, streamName],
);
const cameraMetadata = streamName ? streamMetadata?.[streamName] : undefined;
const { data: cameraMetadata } = useSWR<LiveStreamMetadata>(
isRestreamed ? `go2rtc/streams/${streamName}` : null,
{
revalidateOnFocus: false,
},
);
const supportsAudioOutput = useMemo(() => {
if (!cameraMetadata) {

View File

@ -24,7 +24,7 @@ import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { Link } from "react-router-dom";
import { Switch } from "@/components/ui/switch";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import { isDesktop } from "react-device-detect";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { PiSlidersHorizontalBold } from "react-icons/pi";
@ -58,7 +58,7 @@ export default function DetailStream({
const effectiveTime = currentTime - annotationOffset / 1000;
const [upload, setUpload] = useState<Event | undefined>(undefined);
const [controlsExpanded, setControlsExpanded] = useState(false);
const [alwaysExpandActive, setAlwaysExpandActive] = useUserPersistence(
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
"detailStreamActiveExpanded",
true,
);

View File

@ -6,7 +6,7 @@ import {
useContext,
} from "react";
import { AllGroupsStreamingSettings } from "@/types/frigateConfig";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
type StreamingSettingsContextType = {
allGroupsStreamingSettings: AllGroupsStreamingSettings;
@ -29,7 +29,7 @@ export function StreamingSettingsProvider({
persistedGroupStreamingSettings,
setPersistedGroupStreamingSettings,
isPersistedStreamingSettingsLoaded,
] = useUserPersistence<AllGroupsStreamingSettings>("streaming-settings");
] = usePersistence<AllGroupsStreamingSettings>("streaming-settings");
useEffect(() => {
if (isPersistedStreamingSettingsLoaded) {

View File

@ -1,8 +1,8 @@
import { baseUrl } from "@/api/baseUrl";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useEffect, useState, useMemo } from "react";
import useSWR from "swr";
import { LivePlayerMode } from "@/types/live";
import useDeferredStreamMetadata from "./use-deferred-stream-metadata";
import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
export default function useCameraLiveMode(
cameras: CameraConfig[],
@ -11,9 +11,9 @@ export default function useCameraLiveMode(
) {
const { data: config } = useSWR<FrigateConfig>("config");
// Compute which streams need metadata (restreamed streams only)
const restreamedStreamNames = useMemo(() => {
if (!cameras || !config) return [];
// Get comma-separated list of restreamed stream names for SWR key
const restreamedStreamsKey = useMemo(() => {
if (!cameras || !config) return null;
const streamNames = new Set<string>();
cameras.forEach((camera) => {
@ -32,13 +32,56 @@ export default function useCameraLiveMode(
}
});
return Array.from(streamNames);
return streamNames.size > 0
? Array.from(streamNames).sort().join(",")
: null;
}, [cameras, config, activeStreams]);
// Fetch stream metadata with deferred loading (doesn't block initial render)
const streamMetadata = useDeferredStreamMetadata(restreamedStreamNames);
const streamsFetcher = useCallback(async (key: string) => {
const streamNames = key.split(",");
const metadataPromises = streamNames.map(async (streamName) => {
try {
const response = await fetch(
`${baseUrl}api/go2rtc/streams/${streamName}`,
{
priority: "low",
},
);
if (response.ok) {
const data = await response.json();
return { streamName, data };
}
return { streamName, data: null };
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Failed to fetch metadata for ${streamName}:`, error);
return { streamName, data: null };
}
});
const results = await Promise.allSettled(metadataPromises);
const metadata: { [key: string]: LiveStreamMetadata } = {};
results.forEach((result) => {
if (result.status === "fulfilled" && result.value.data) {
metadata[result.value.streamName] = result.value.data;
}
});
return metadata;
}, []);
const { data: allStreamMetadata = {} } = useSWR<{
[key: string]: LiveStreamMetadata;
}>(restreamedStreamsKey, streamsFetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
});
// Compute live mode states
const [preferredLiveModes, setPreferredLiveModes] = useState<{
[key: string]: LivePlayerMode;
}>({});
@ -79,10 +122,10 @@ export default function useCameraLiveMode(
newPreferredLiveModes[camera.name] = isRestreamed ? "mse" : "jsmpeg";
}
// Check each stream for audio support
// check each stream for audio support
if (isRestreamed) {
Object.values(camera.live.streams).forEach((streamName) => {
const metadata = streamMetadata[streamName];
const metadata = allStreamMetadata?.[streamName];
newSupportsAudioOutputStates[streamName] = {
supportsAudio: metadata
? metadata.producers.find(
@ -107,7 +150,7 @@ export default function useCameraLiveMode(
setPreferredLiveModes(newPreferredLiveModes);
setIsRestreamedStates(newIsRestreamedStates);
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
}, [cameras, config, windowVisible, streamMetadata]);
}, [cameras, config, windowVisible, allStreamMetadata]);
const resetPreferredLiveMode = useCallback(
(cameraName: string) => {
@ -137,6 +180,5 @@ export default function useCameraLiveMode(
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
streamMetadata,
};
}

View File

@ -1,90 +0,0 @@
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;
}

View File

@ -1,8 +1,6 @@
import { useCallback, useContext, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { usePersistence } from "./use-persistence";
import { useUserPersistence } from "./use-user-persistence";
import { AuthContext } from "@/context/auth-context";
export function useOverlayState<S>(
key: string,
@ -81,60 +79,6 @@ export function usePersistedOverlayState<S extends string>(
];
}
/**
* Like usePersistedOverlayState, but namespaces the persistence key by username.
* This ensures different users on the same browser don't share state.
* Automatically migrates data from legacy (non-namespaced) keys on first use.
*/
export function useUserPersistedOverlayState<S extends string>(
key: string,
defaultValue: S | undefined = undefined,
): [
S | undefined,
(value: S | undefined, replace?: boolean) => void,
boolean,
() => void,
] {
const { auth } = useContext(AuthContext);
const location = useLocation();
const navigate = useNavigate();
const currentLocationState = useMemo(() => location.state, [location]);
// currently selected value from URL state
const overlayStateValue = useMemo<S | undefined>(
() => location.state && location.state[key],
[location, key],
);
// saved value from previous session (user-namespaced with migration)
const [persistedValue, setPersistedValue, loaded, deletePersistedValue] =
useUserPersistence<S>(key, overlayStateValue);
const setOverlayStateValue = useCallback(
(value: S | undefined, replace: boolean = false) => {
setPersistedValue(value);
const newLocationState = { ...currentLocationState };
newLocationState[key] = value;
navigate(location.pathname, { state: newLocationState, replace });
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[key, currentLocationState, navigate, setPersistedValue],
);
// Don't return a value until auth has finished loading
if (auth.isLoading) {
return [undefined, setOverlayStateValue, false, deletePersistedValue];
}
return [
overlayStateValue ?? persistedValue ?? defaultValue,
setOverlayStateValue,
loaded,
deletePersistedValue,
];
}
export function useHashState<S extends string>(): [
S | undefined,
(value: S) => void,

View File

@ -1,199 +0,0 @@
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];
}

View File

@ -3,7 +3,7 @@ import useApiFilter from "@/hooks/use-api-filter";
import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { useTimezone } from "@/hooks/use-date-utils";
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import { FrigateConfig } from "@/types/frigateConfig";
import { RecordingStartingPoint } from "@/types/record";
import {
@ -42,10 +42,7 @@ export default function Events() {
"alert",
);
const [showReviewed, setShowReviewed] = useUserPersistence(
"showReviewed",
false,
);
const [showReviewed, setShowReviewed] = usePersistence("showReviewed", false);
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>(
"recording",

View File

@ -7,7 +7,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar";
import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { useTimezone } from "@/hooks/use-date-utils";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import { FrigateConfig } from "@/types/frigateConfig";
import { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
import { ModelState } from "@/types/ws";
@ -47,10 +47,7 @@ export default function Explore() {
// grid
const [columnCount, setColumnCount] = useUserPersistence(
"exploreGridColumns",
4,
);
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
const gridColumns = useMemo(() => {
if (isMobileOnly) {
return 2;
@ -60,7 +57,7 @@ export default function Explore() {
// default layout
const [defaultView, setDefaultView, defaultViewLoaded] = useUserPersistence(
const [defaultView, setDefaultView, defaultViewLoaded] = usePersistence(
"exploreDefaultView",
"summary",
);

View File

@ -1,7 +1,10 @@
import { useFullscreen } from "@/hooks/use-fullscreen";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useHashState, useSearchEffect } from "@/hooks/use-overlay-state";
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
import {
useHashState,
usePersistedOverlayState,
useSearchEffect,
} from "@/hooks/use-overlay-state";
import { FrigateConfig } from "@/types/frigateConfig";
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
import LiveCameraView from "@/views/live/LiveCameraView";
@ -21,7 +24,7 @@ function Live() {
// selection
const [selectedCameraName, setSelectedCameraName] = useHashState();
const [cameraGroup, setCameraGroup, loaded] = useUserPersistedOverlayState(
const [cameraGroup, setCameraGroup, loaded, ,] = usePersistedOverlayState(
"cameraGroup",
"default" as string,
);

View File

@ -305,7 +305,6 @@ export type CustomClassificationModelConfig = {
enabled: boolean;
name: string;
threshold: number;
save_attempts?: number;
object_config?: {
objects: string[];
classification_type: string;

View File

@ -1,4 +1,4 @@
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import {
AllGroupsStreamingSettings,
BirdseyeConfig,
@ -24,7 +24,6 @@ import "react-resizable/css/styles.css";
import {
AudioState,
LivePlayerMode,
LiveStreamMetadata,
StatsState,
VolumeState,
} from "@/types/live";
@ -40,7 +39,7 @@ import { IoClose } from "react-icons/io5";
import { LuLayoutDashboard, LuPencil } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { EditGroupDialog } from "@/components/filter/CameraGroupSelector";
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
import { FaCompress, FaExpand } from "react-icons/fa";
import {
Tooltip,
@ -48,6 +47,7 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next";
@ -65,16 +65,6 @@ type DraggableGridLayoutProps = {
setIsEditMode: React.Dispatch<React.SetStateAction<boolean>>;
fullscreen: boolean;
toggleFullscreen: () => void;
preferredLiveModes: { [key: string]: LivePlayerMode };
setPreferredLiveModes: React.Dispatch<
React.SetStateAction<{ [key: string]: LivePlayerMode }>
>;
resetPreferredLiveMode: (cameraName: string) => void;
isRestreamedStates: { [key: string]: boolean };
supportsAudioOutputStates: {
[key: string]: { supportsAudio: boolean; cameraName: string };
};
streamMetadata: { [key: string]: LiveStreamMetadata };
};
export default function DraggableGridLayout({
cameras,
@ -89,12 +79,6 @@ export default function DraggableGridLayout({
setIsEditMode,
fullscreen,
toggleFullscreen,
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
streamMetadata,
}: DraggableGridLayoutProps) {
const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config");
@ -102,8 +86,8 @@ export default function DraggableGridLayout({
// preferred live modes per camera
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
const [globalAutoLive] = usePersistence("autoLiveView", true);
const [displayCameraNames] = usePersistence("displayCameraNames", false);
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useStreamingSettings();
@ -114,18 +98,42 @@ export default function DraggableGridLayout({
}
}, [allGroupsStreamingSettings, cameraGroup]);
const activeStreams = useMemo(() => {
const streams: { [cameraName: string]: string } = {};
cameras.forEach((camera) => {
const availableStreams = camera.live.streams || {};
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(streamNameFromSettings);
const streamName = streamExists
? streamNameFromSettings
: Object.values(availableStreams)[0] || "";
streams[camera.name] = streamName;
});
return streams;
}, [cameras, currentGroupStreamingSettings]);
const {
preferredLiveModes,
setPreferredLiveModes,
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
// grid layout
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
const [gridLayout, setGridLayout, isGridLayoutLoaded] = useUserPersistence<
const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence<
Layout[]
>(`${cameraGroup}-draggable-layout`);
const [group] = useUserPersistedOverlayState(
"cameraGroup",
"default" as string,
);
const [group] = usePersistedOverlayState("cameraGroup", "default" as string);
const groups = useMemo(() => {
if (!config) {
@ -145,11 +153,6 @@ export default function DraggableGridLayout({
useEffect(() => {
setIsEditMode(false);
setEditGroup(false);
// Reset camera tracking state when group changes to prevent the camera-change
// effect from incorrectly overwriting the loaded layout
setCurrentCameras(undefined);
setCurrentIncludeBirdseye(undefined);
setCurrentGridLayout(undefined);
}, [cameraGroup, setIsEditMode]);
// camera state
@ -173,120 +176,104 @@ export default function DraggableGridLayout({
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
);
const generateLayout = useCallback(
(baseLayout: Layout[] | undefined) => {
if (!isGridLayoutLoaded) {
const generateLayout = useCallback(() => {
if (!isGridLayoutLoaded) {
return;
}
const cameraNames =
includeBirdseye && birdseyeConfig?.enabled
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
: cameras.map((camera) => camera?.name || "");
const optionsMap: Layout[] = currentGridLayout
? currentGridLayout.filter((layout) => cameraNames?.includes(layout.i))
: [];
cameraNames.forEach((cameraName, index) => {
const existingLayout = optionsMap.find(
(layout) => layout.i === cameraName,
);
// Skip if the camera already exists in the layout
if (existingLayout) {
return;
}
const cameraNames =
includeBirdseye && birdseyeConfig?.enabled
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
: cameras.map((camera) => camera?.name || "");
let aspectRatio;
let col;
const optionsMap: Layout[] = baseLayout
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
: [];
// 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
}
cameraNames.forEach((cameraName, index) => {
const existingLayout = optionsMap.find(
(layout) => layout.i === cameraName,
);
// Calculate layout options based on aspect ratio
const columnsPerPlayer = 4;
let height;
let width;
// Skip if the camera already exists in the layout
if (existingLayout) {
return;
}
if (aspectRatio < 1) {
// Portrait
height = 2 * columnsPerPlayer;
width = columnsPerPlayer;
} else if (aspectRatio > 2) {
// Wide
height = 1 * columnsPerPlayer;
width = 2 * columnsPerPlayer;
} else {
// Landscape
height = 1 * columnsPerPlayer;
width = columnsPerPlayer;
}
let aspectRatio;
let col;
const options = {
i: cameraName,
x: col * width,
y: 0, // don't set y, grid does automatically
w: width,
h: height,
};
// 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
}
optionsMap.push(options);
});
// Calculate layout options based on aspect ratio
const columnsPerPlayer = 4;
let height;
let width;
if (aspectRatio < 1) {
// Portrait
height = 2 * columnsPerPlayer;
width = columnsPerPlayer;
} else if (aspectRatio > 2) {
// Wide
height = 1 * columnsPerPlayer;
width = 2 * columnsPerPlayer;
} else {
// Landscape
height = 1 * columnsPerPlayer;
width = columnsPerPlayer;
}
const options = {
i: cameraName,
x: col * width,
y: 0, // don't set y, grid does automatically
w: width,
h: height,
};
optionsMap.push(options);
});
return optionsMap;
},
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig],
);
return optionsMap;
}, [
cameras,
isGridLayoutLoaded,
currentGridLayout,
includeBirdseye,
birdseyeConfig,
]);
useEffect(() => {
if (isGridLayoutLoaded) {
if (gridLayout) {
// set current grid layout from loaded, possibly adding new cameras
const updatedLayout = generateLayout(gridLayout);
setCurrentGridLayout(updatedLayout);
// Only save if cameras were added (layout changed)
if (!isEqual(updatedLayout, gridLayout)) {
setGridLayout(updatedLayout);
}
// Set camera tracking state so the camera-change effect has a baseline
setCurrentCameras(cameras);
setCurrentIncludeBirdseye(includeBirdseye);
// set current grid layout from loaded
setCurrentGridLayout(gridLayout);
} else {
// idb is empty, set it with an initial layout
const newLayout = generateLayout(undefined);
setCurrentGridLayout(newLayout);
setGridLayout(newLayout);
setCurrentCameras(cameras);
setCurrentIncludeBirdseye(includeBirdseye);
setGridLayout(generateLayout());
}
}
}, [
isEditMode,
gridLayout,
currentGridLayout,
setGridLayout,
isGridLayoutLoaded,
generateLayout,
cameras,
includeBirdseye,
]);
useEffect(() => {
// Only regenerate layout when cameras change WITHIN an already-loaded group
// Skip if currentCameras is undefined (means we just switched groups and
// the first useEffect hasn't run yet to set things up)
if (!isGridLayoutLoaded || currentCameras === undefined) {
return;
}
if (
!isEqual(cameras, currentCameras) ||
includeBirdseye !== currentIncludeBirdseye
@ -294,17 +281,15 @@ export default function DraggableGridLayout({
setCurrentCameras(cameras);
setCurrentIncludeBirdseye(includeBirdseye);
// Regenerate layout based on current layout, adding any new cameras
const updatedLayout = generateLayout(currentGridLayout);
setCurrentGridLayout(updatedLayout);
setGridLayout(updatedLayout);
// set new grid layout in idb
setGridLayout(generateLayout());
}
}, [
cameras,
includeBirdseye,
currentCameras,
currentIncludeBirdseye,
currentGridLayout,
setCurrentGridLayout,
generateLayout,
setGridLayout,
isGridLayoutLoaded,
@ -639,7 +624,6 @@ export default function DraggableGridLayout({
resetPreferredLiveMode(camera.name)
}
config={config}
streamMetadata={streamMetadata}
>
<LivePlayer
key={camera.name}
@ -854,7 +838,6 @@ type GridLiveContextMenuProps = {
unmuteAll: () => void;
resetPreferredLiveMode: () => void;
config?: FrigateConfig;
streamMetadata?: { [key: string]: LiveStreamMetadata };
};
const GridLiveContextMenu = React.forwardRef<
@ -885,7 +868,6 @@ const GridLiveContextMenu = React.forwardRef<
unmuteAll,
resetPreferredLiveMode,
config,
streamMetadata,
...props
},
ref,
@ -917,7 +899,6 @@ const GridLiveContextMenu = React.forwardRef<
unmuteAll={unmuteAll}
resetPreferredLiveMode={resetPreferredLiveMode}
config={config}
streamMetadata={streamMetadata}
>
{children}
</LiveContextMenu>

View File

@ -101,7 +101,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import axios from "axios";
@ -146,7 +146,7 @@ export default function LiveCameraView({
// supported features
const [streamName, setStreamName] = useUserPersistence<string>(
const [streamName, setStreamName] = usePersistence<string>(
`${camera.name}-stream`,
Object.values(camera.live.streams)[0],
);
@ -279,7 +279,7 @@ export default function LiveCameraView({
const [pip, setPip] = useState(false);
const [lowBandwidth, setLowBandwidth] = useState(false);
const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
const [playInBackground, setPlayInBackground] = usePersistence<boolean>(
`${camera.name}-background-play`,
false,
);

View File

@ -13,7 +13,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import {
AllGroupsStreamingSettings,
CameraConfig,
@ -78,7 +78,7 @@ export default function LiveDashboardView({
// layout
const [mobileLayout, setMobileLayout] = useUserPersistence<"grid" | "list">(
const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">(
"live-layout",
isDesktop ? "grid" : "list",
);
@ -211,8 +211,8 @@ export default function LiveDashboardView({
};
}, []);
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
const [globalAutoLive] = usePersistence("autoLiveView", true);
const [displayCameraNames] = usePersistence("displayCameraNames", false);
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
useStreamingSettings();
@ -265,7 +265,6 @@ export default function LiveDashboardView({
resetPreferredLiveMode,
isRestreamedStates,
supportsAudioOutputStates,
streamMetadata,
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@ -651,12 +650,6 @@ export default function LiveDashboardView({
setIsEditMode={setIsEditMode}
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
preferredLiveModes={preferredLiveModes}
setPreferredLiveModes={setPreferredLiveModes}
resetPreferredLiveMode={resetPreferredLiveMode}
isRestreamedStates={isRestreamedStates}
supportsAudioOutputStates={supportsAudioOutputStates}
streamMetadata={streamMetadata}
/>
)}
</>

View File

@ -478,32 +478,33 @@ export default function AuthenticationView({
<TableCell className="text-right">
<TooltipProvider>
<div className="flex items-center justify-end gap-2">
{user.username !== "admin" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => {
setSelectedUser(user.username);
setSelectedUserRole(
user.role || "viewer",
);
setShowRoleChange(true);
}}
>
<LuUserCog className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("role.title", { ns: "common" })}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("users.table.changeRole")}</p>
</TooltipContent>
</Tooltip>
)}
{user.username !== "admin" &&
user.username !== "viewer" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 px-2"
onClick={() => {
setSelectedUser(user.username);
setSelectedUserRole(
user.role || "viewer",
);
setShowRoleChange(true);
}}
>
<LuUserCog className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
{t("role.title", { ns: "common" })}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("users.table.changeRole")}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>

View File

@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
import useSWR from "swr";
import Heading from "@/components/ui/heading";
import { Switch } from "@/components/ui/switch";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { usePersistence } from "@/hooks/use-persistence";
import { Skeleton } from "@/components/ui/skeleton";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -104,7 +104,7 @@ export default function ObjectSettingsView({
},
];
const [options, setOptions, optionsLoaded] = useUserPersistence<Options>(
const [options, setOptions, optionsLoaded] = usePersistence<Options>(
`${selectedCamera}-feed`,
emptyObject,
);

View File

@ -1,17 +1,15 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useCallback, useContext, useEffect } from "react";
import { useCallback, useEffect } from "react";
import { Toaster } from "sonner";
import { toast } from "sonner";
import { Separator } from "../../components/ui/separator";
import { Button } from "../../components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import {
useUserPersistence,
deleteUserNamespacedKey,
} from "@/hooks/use-user-persistence";
import { del as delData } from "idb-keyval";
import { usePersistence } from "@/hooks/use-persistence";
import { isSafari } from "react-device-detect";
import {
Select,
@ -21,7 +19,6 @@ import {
SelectTrigger,
} from "../../components/ui/select";
import { useTranslation } from "react-i18next";
import { AuthContext } from "@/context/auth-context";
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
const WEEK_STARTS_ON = ["Sunday", "Monday"];
@ -29,16 +26,13 @@ const WEEK_STARTS_ON = ["Sunday", "Monday"];
export default function UiSettingsView() {
const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation("views/settings");
const { auth } = useContext(AuthContext);
const username = auth?.user?.username;
const clearStoredLayouts = useCallback(() => {
if (!config) {
return [];
}
Object.entries(config.camera_groups).forEach(async (value) => {
await deleteUserNamespacedKey(`${value[0]}-draggable-layout`, username)
await delData(`${value[0]}-draggable-layout`)
.then(() => {
toast.success(
t("general.toast.success.clearStoredLayout", {
@ -62,14 +56,14 @@ export default function UiSettingsView() {
);
});
});
}, [config, t, username]);
}, [config, t]);
const clearStreamingSettings = useCallback(async () => {
if (!config) {
return [];
}
await deleteUserNamespacedKey(`streaming-settings`, username)
await delData(`streaming-settings`)
.then(() => {
toast.success(t("general.toast.success.clearStreamingSettings"), {
position: "top-center",
@ -89,7 +83,7 @@ export default function UiSettingsView() {
},
);
});
}, [config, t, username]);
}, [config, t]);
useEffect(() => {
document.title = t("documentTitle.general");
@ -97,15 +91,15 @@ export default function UiSettingsView() {
// settings
const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true);
const [cameraNames, setCameraName] = useUserPersistence(
const [autoLive, setAutoLive] = usePersistence("autoLiveView", true);
const [cameraNames, setCameraName] = usePersistence(
"displayCameraNames",
false,
);
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
const [fallbackTimeout, setFallbackTimeout] = useUserPersistence(
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
const [fallbackTimeout, setFallbackTimeout] = usePersistence(
"liveFallbackTimeout",
3,
);