Compare commits

...

6 Commits

Author SHA1 Message Date
leccelecce
e2c37eb2d7
Merge 314b842109 into 9d4aac2b8e 2025-12-02 14:12:01 +01:00
GuoQing Liu
9d4aac2b8e
Revise the README_CN (#21048)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* docs: update chinese readme

* style: Improve the styling of the Chinese document jump tips bar in dark mode

* docs: add license translation
2025-12-01 10:52:30 -07:00
Nicolas Mowen
aa09132dfd
Update ROCm to 7.1.1 (#21113)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* Update ROCm to 7.1.1

* testing for build

* Fix

* remove debug
2025-12-01 08:07:35 -07:00
Josh Hawkins
24766ce427
Use user-namespaced keys for idb persistence (#21110)
* add new hooks

* use new hooks for user based keys

* fix layout race condition
2025-12-01 07:59:54 -06:00
Nicolas Mowen
97b29d177a
Miscellaneous Fixes (#21072)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* Implement renaming in model editing dialog

* add transcription faq

* remove incorrect constraint for viewer as username

should be able to change anyone's role other than admin

* Don't save redundant state changes

* prevent crash when a camera doesn't support onvif imaging service required for focus support

* Fine tune behavior

* Stop redundant go2rtc stream metadata requests and defer audio information to allow bandwidth for image requests

* Improve cleanup logic for capture process

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
2025-11-30 06:54:42 -06:00
leccelecce
314b842109 Nginx: disable open_file_cache if media is on NFS 2025-03-19 13:06:57 +00:00
37 changed files with 854 additions and 352 deletions

View File

@ -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:

View File

@ -1,23 +1,26 @@
<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) | \[简体中文\]
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 全天候录制
@ -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)
Bilibilihttps://space.bilibili.com/3546894915602564 Bilibilihttps://space.bilibili.com/3546894915602564
## 中文社区赞助商 ## 中文社区赞助商
[![EdgeOne](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)](https://edgeone.ai/zh?from=github) [![EdgeOne](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)](https://edgeone.ai/zh?from=github)
本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助 本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助
---
**Copyright © 2025 Frigate LLC.**

View File

@ -89,6 +89,15 @@ python3 /usr/local/nginx/get_listen_settings.py | \
tempio -template /usr/local/nginx/templates/listen.gotmpl \ tempio -template /usr/local/nginx/templates/listen.gotmpl \
-out /usr/local/nginx/conf/listen.conf -out /usr/local/nginx/conf/listen.conf
# disable open_file_cache on NFS filesystems
# https://trac.nginx.org/nginx/ticket/478
NFS_CLIPS_FS=`findmnt -n -o FSTYPE --target /media/frigate/clips`
shopt -s nocasematch; if [[ "$NFS_CLIPS_FS" =~ ^nfs* ]]; then
echo "[INFO] NFS mount detected for /media/frigate/clips, disabling NGINX open_file_cache"
echo "open_file_cache off;" > /usr/local/nginx/conf/open_file_cache.conf
fi
# Replace the bash process with the NGINX process, redirecting stderr to stdout # Replace the bash process with the NGINX process, redirecting stderr to stdout
exec 2>&1 exec 2>&1
exec \ exec \

View File

@ -79,10 +79,7 @@ http {
vod_hls_mpegts_interleave_frames on; vod_hls_mpegts_interleave_frames on;
# file handle caching / aio # file handle caching / aio
open_file_cache max=1000 inactive=5m; include open_file_cache.conf;
open_file_cache_valid 2m;
open_file_cache_min_uses 1;
open_file_cache_errors on;
aio on; aio on;
# file upload size # file upload size

View File

@ -0,0 +1,4 @@
open_file_cache max=1000 inactive=5m;
open_file_cache_valid 2m;
open_file_cache_min_uses 1;
open_file_cache_errors on;

View File

@ -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

View File

@ -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 = ""

View File

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

@ -6,6 +6,11 @@
font-size: 15px; font-size: 15px;
} }
[data-theme="dark"] .alert {
background: #3b2f0b;
border-bottom: 1px solid #665c22;
}
.alert a { .alert a {
color: #1890ff; color: #1890ff;
font-weight: 500; font-weight: 500;

View File

@ -99,6 +99,42 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
if self.inference_speed: if self.inference_speed:
self.inference_speed.update(duration) self.inference_speed.update(duration)
def _should_save_image(
self, camera: str, detected_state: str, score: float = 1.0
) -> bool:
"""
Determine if we should save the image for training.
Save when:
- State is changing or being verified (regardless of score)
- Score is less than 100% (even if state matches, useful for training)
Don't save when:
- State is stable (matches current_state) AND score is 100%
"""
if camera not in self.state_history:
# First detection for this camera, save it
return True
verification = self.state_history[camera]
current_state = verification.get("current_state")
pending_state = verification.get("pending_state")
# Save if there's a pending state change being verified
if pending_state is not None:
return True
# Save if the detected state differs from the current verified state
# (state is changing)
if current_state is not None and detected_state != current_state:
return True
# If score is less than 100%, save even if state matches
# (useful for training to improve confidence)
if score < 1.0:
return True
# Don't save if state is stable (detected_state == current_state) AND score is 100%
return False
def verify_state_change(self, camera: str, detected_state: str) -> str | None: def verify_state_change(self, camera: str, detected_state: str) -> str | None:
""" """
Verify state change requires 3 consecutive identical states before publishing. Verify state change requires 3 consecutive identical states before publishing.
@ -212,6 +248,8 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
return return
if self.interpreter is None: 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):
write_classification_attempt( write_classification_attempt(
self.train_dir, self.train_dir,
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR), cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
@ -236,12 +274,15 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
score = round(probs[best_id], 2) score = round(probs[best_id], 2)
self.__update_metrics(datetime.datetime.now().timestamp() - now) self.__update_metrics(datetime.datetime.now().timestamp() - now)
detected_state = self.labelmap[best_id]
if self._should_save_image(camera, detected_state, score):
write_classification_attempt( write_classification_attempt(
self.train_dir, self.train_dir,
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR), cv2.cvtColor(frame, cv2.COLOR_RGB2BGR),
"none-none", "none-none",
now, now,
self.labelmap[best_id], detected_state,
score, score,
) )
@ -251,7 +292,6 @@ class CustomStateClassificationProcessor(RealTimeProcessorApi):
) )
return return
detected_state = self.labelmap[best_id]
verified_state = self.verify_state_change(camera, detected_state) verified_state = self.verify_state_change(camera, detected_state)
if verified_state is not None: if verified_state is not None:

View File

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

View File

@ -124,6 +124,7 @@ def capture_frames(
config_subscriber.check_for_updates() config_subscriber.check_for_updates()
return config.enabled return config.enabled
try:
while not stop_event.is_set(): while not stop_event.is_set():
if not get_enabled_state(): if not get_enabled_state():
logger.debug(f"Stopping capture thread for disabled {config.name}") logger.debug(f"Stopping capture thread for disabled {config.name}")
@ -141,7 +142,9 @@ def capture_frames(
if stop_event.is_set(): if stop_event.is_set():
break break
logger.error(f"{config.name}: Unable to read frames from ffmpeg process.") logger.error(
f"{config.name}: Unable to read frames from ffmpeg process."
)
if ffmpeg_process.poll() is not None: if ffmpeg_process.poll() is not None:
logger.error( logger.error(
@ -163,6 +166,8 @@ def capture_frames(
skipped_eps.update() skipped_eps.update()
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1 frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
finally:
config_subscriber.stop()
class CameraWatchdog(threading.Thread): class CameraWatchdog(threading.Thread):
@ -234,6 +239,16 @@ class CameraWatchdog(threading.Thread):
else: else:
self.ffmpeg_detect_process.wait() self.ffmpeg_detect_process.wait()
# Wait for old capture thread to fully exit before starting a new one
if self.capture_thread is not None and self.capture_thread.is_alive():
self.logger.info("Waiting for capture thread to exit...")
self.capture_thread.join(timeout=5)
if self.capture_thread.is_alive():
self.logger.warning(
f"Capture thread for {self.config.name} did not exit in time"
)
self.logger.error( self.logger.error(
"The following ffmpeg logs include the last 100 lines prior to exit." "The following ffmpeg logs include the last 100 lines prior to exit."
) )

View File

@ -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,
); );

View File

@ -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,
); );

View File

@ -37,7 +37,7 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuPlus, LuX } from "react-icons/lu"; import { LuPlus, LuX } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR, { mutate } from "swr";
import { z } from "zod"; import { z } from "zod";
type ClassificationModelEditDialogProps = { type ClassificationModelEditDialogProps = {
@ -240,24 +240,72 @@ export default function ClassificationModelEditDialog({
position: "top-center", position: "top-center",
}); });
} else { } else {
// State model - update classes const stateData = data as StateFormData;
// Note: For state models, updating classes requires renaming categories const newClasses = stateData.classes.filter(
// which is handled through the dataset API, not the config API (c) => c.trim().length > 0,
// We'll need to implement this by calling the rename endpoint for each class );
// For now, we just show a message that this requires retraining const oldClasses = dataset?.categories
? Object.keys(dataset.categories).filter((key) => key !== "none")
: [];
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"), { toast.info(t("edit.stateClassesInfo"), {
position: "top-center", position: "top-center",
}); });
} }
}
onSuccess(); onSuccess();
onClose(); onClose();
} catch (err) { } catch (err) {
const error = err as { const error = err as {
response?: { data?: { message?: string; detail?: string } }; response?: { data?: { message?: string; detail?: string } };
message?: string;
}; };
const errorMessage = const errorMessage =
error.message ||
error.response?.data?.message || error.response?.data?.message ||
error.response?.data?.detail || error.response?.data?.detail ||
"Unknown error"; "Unknown error";
@ -268,7 +316,7 @@ export default function ClassificationModelEditDialog({
setIsSaving(false); setIsSaving(false);
} }
}, },
[isObjectModel, model, t, onSuccess, onClose], [isObjectModel, model, dataset, t, onSuccess, onClose],
); );
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {

View File

@ -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`,
); );

View File

@ -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);

View File

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

View File

@ -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(
() => () =>

View File

@ -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,

View File

@ -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);

View File

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

View File

@ -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,
); );

View File

@ -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) {

View File

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

View File

@ -0,0 +1,90 @@
import { baseUrl } from "@/api/baseUrl";
import { useCallback, useEffect, useState, useMemo } from "react";
import useSWR from "swr";
import { LiveStreamMetadata } from "@/types/live";
const FETCH_TIMEOUT_MS = 10000;
const DEFER_DELAY_MS = 2000;
/**
* Hook that fetches go2rtc stream metadata with deferred loading.
*
* Metadata fetching is delayed to prevent blocking initial page load
* and camera image requests.
*
* @param streamNames - Array of stream names to fetch metadata for
* @returns Object containing stream metadata keyed by stream name
*/
export default function useDeferredStreamMetadata(streamNames: string[]) {
const [fetchEnabled, setFetchEnabled] = useState(false);
useEffect(() => {
const timeoutId = setTimeout(() => {
setFetchEnabled(true);
}, DEFER_DELAY_MS);
return () => clearTimeout(timeoutId);
}, []);
const swrKey = useMemo(() => {
if (!fetchEnabled || streamNames.length === 0) return null;
// Use spread to avoid mutating the original array
return `deferred-streams:${[...streamNames].sort().join(",")}`;
}, [fetchEnabled, streamNames]);
const fetcher = useCallback(async (key: string) => {
// Extract stream names from key (remove prefix)
const names = key.replace("deferred-streams:", "").split(",");
const promises = names.map(async (streamName) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(
`${baseUrl}api/go2rtc/streams/${streamName}`,
{
priority: "low",
signal: controller.signal,
},
);
clearTimeout(timeoutId);
if (response.ok) {
const data = await response.json();
return { streamName, data };
}
return { streamName, data: null };
} catch (error) {
clearTimeout(timeoutId);
if ((error as Error).name !== "AbortError") {
// eslint-disable-next-line no-console
console.error(`Failed to fetch metadata for ${streamName}:`, error);
}
return { streamName, data: null };
}
});
const results = await Promise.allSettled(promises);
const metadata: { [key: string]: LiveStreamMetadata } = {};
results.forEach((result) => {
if (result.status === "fulfilled" && result.value.data) {
metadata[result.value.streamName] = result.value.data;
}
});
return metadata;
}, []);
const { data: metadata = {} } = useSWR<{
[key: string]: LiveStreamMetadata;
}>(swrKey, fetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 60000,
});
return metadata;
}

View File

@ -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,

View 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];
}

View File

@ -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",

View File

@ -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",
); );

View File

@ -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,
); );

View File

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

View File

@ -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,
); );

View File

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

View File

@ -478,8 +478,7 @@ export default function AuthenticationView({
<TableCell className="text-right"> <TableCell className="text-right">
<TooltipProvider> <TooltipProvider>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{user.username !== "admin" && {user.username !== "admin" && (
user.username !== "viewer" && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button

View File

@ -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,
); );

View File

@ -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,
); );