mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 05:24:11 +03:00
Compare commits
4 Commits
ea068aef17
...
6ac79699d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ac79699d6 | ||
|
|
9d4aac2b8e | ||
|
|
aa09132dfd | ||
|
|
24766ce427 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -15,7 +15,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: 3.9
|
||||
PYTHON_VERSION: 3.11
|
||||
|
||||
jobs:
|
||||
amd64_build:
|
||||
|
||||
53
README_CN.md
53
README_CN.md
@ -1,28 +1,31 @@
|
||||
<p align="center">
|
||||
<img align="center" alt="logo" src="docs/static/img/frigate.png">
|
||||
<img align="center" alt="logo" src="docs/static/img/branding/frigate.png">
|
||||
</p>
|
||||
|
||||
# Frigate - 一个具有实时目标检测的本地NVR
|
||||
# Frigate NVR™ - 一个具有实时目标检测的本地 NVR
|
||||
|
||||
[English](https://github.com/blakeblackshear/frigate) | \[简体中文\]
|
||||
[English](https://github.com/blakeblackshear/frigate) | \[简体中文\]
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/frigate-nvr/-/zh_Hans/">
|
||||
<img src="https://hosted.weblate.org/widget/frigate-nvr/-/zh_Hans/svg-badge.svg" alt="翻译状态" />
|
||||
</a>
|
||||
|
||||
一个完整的本地网络视频录像机(NVR),专为[Home Assistant](https://www.home-assistant.io)设计,具备AI物体检测功能。使用OpenCV和TensorFlow在本地为IP摄像头执行实时物体检测。
|
||||
一个完整的本地网络视频录像机(NVR),专为[Home Assistant](https://www.home-assistant.io)设计,具备 AI 目标/物体检测功能。使用 OpenCV 和 TensorFlow 在本地为 IP 摄像头执行实时物体检测。
|
||||
|
||||
强烈推荐使用GPU或者AI加速器(例如[Google Coral加速器](https://coral.ai/products/) 或者 [Hailo](https://hailo.ai/))。它们的性能甚至超过目前的顶级CPU,并且可以以极低的耗电实现更优的性能。
|
||||
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与Home Assistant紧密集成
|
||||
- 设计上通过仅在必要时和必要地点寻找物体,最大限度地减少资源使用并最大化性能
|
||||
强烈推荐使用 GPU 或者 AI 加速器(例如[Google Coral 加速器](https://coral.ai/products/) 或者 [Hailo](https://hailo.ai/)等)。它们的运行效率远远高于现在的顶级 CPU,并且功耗也极低。
|
||||
|
||||
- 通过[自定义组件](https://github.com/blakeblackshear/frigate-hass-integration)与 Home Assistant 紧密集成
|
||||
- 设计上通过仅在必要时和必要地点寻找目标,最大限度地减少资源使用并最大化性能
|
||||
- 大量利用多进程处理,强调实时性而非处理每一帧
|
||||
- 使用非常低开销的运动检测来确定运行物体检测的位置
|
||||
- 使用TensorFlow进行物体检测,运行在单独的进程中以达到最大FPS
|
||||
- 通过MQTT进行通信,便于集成到其他系统中
|
||||
- 使用非常低开销的画面变动检测(也叫运动检测)来确定运行目标检测的位置
|
||||
- 使用 TensorFlow 进行目标检测,并运行在单独的进程中以达到最大 FPS
|
||||
- 通过 MQTT 进行通信,便于集成到其他系统中
|
||||
- 根据检测到的物体设置保留时间进行视频录制
|
||||
- 24/7全天候录制
|
||||
- 通过RTSP重新流传输以减少摄像头的连接数
|
||||
- 支持WebRTC和MSE,实现低延迟的实时观看
|
||||
- 24/7 全天候录制
|
||||
- 通过 RTSP 重新流传输以减少摄像头的连接数
|
||||
- 支持 WebRTC 和 MSE,实现低延迟的实时观看
|
||||
|
||||
## 社区中文翻译文档
|
||||
|
||||
@ -32,39 +35,55 @@
|
||||
|
||||
如果您想通过捐赠支持开发,请使用 [Github Sponsors](https://github.com/sponsors/blakeblackshear)。
|
||||
|
||||
## 协议
|
||||
|
||||
本项目采用 **MIT 许可证**授权。
|
||||
**代码部分**:本代码库中的源代码、配置文件和文档均遵循 [MIT 许可证](LICENSE)。您可以自由使用、修改和分发这些代码,但必须保留原始版权声明。
|
||||
|
||||
**商标部分**:“Frigate”名称、“Frigate NVR”品牌以及 Frigate 的 Logo 为 **Frigate LLC 的商标**,**不在** MIT 许可证覆盖范围内。
|
||||
有关品牌资产的规范使用详情,请参阅我们的[《商标政策》](TRADEMARK.md)。
|
||||
|
||||
## 截图
|
||||
|
||||
### 实时监控面板
|
||||
|
||||
<div>
|
||||
<img width="800" alt="实时监控面板" src="https://github.com/blakeblackshear/frigate/assets/569905/5e713cb9-9db5-41dc-947a-6937c3bc376e">
|
||||
</div>
|
||||
|
||||
### 简单的核查工作流程
|
||||
|
||||
<div>
|
||||
<img width="800" alt="简单的审查工作流程" src="https://github.com/blakeblackshear/frigate/assets/569905/6fed96e8-3b18-40e5-9ddc-31e6f3c9f2ff">
|
||||
</div>
|
||||
|
||||
### 多摄像头可按时间轴查看
|
||||
|
||||
<div>
|
||||
<img width="800" alt="多摄像头可按时间轴查看" src="https://github.com/blakeblackshear/frigate/assets/569905/d6788a15-0eeb-4427-a8d4-80b93cae3d74">
|
||||
</div>
|
||||
|
||||
### 内置遮罩和区域编辑器
|
||||
|
||||
<div>
|
||||
<img width="800" alt="内置遮罩和区域编辑器" src="https://github.com/blakeblackshear/frigate/assets/569905/d7885fc3-bfe6-452f-b7d0-d957cb3e31f5">
|
||||
</div>
|
||||
|
||||
|
||||
## 翻译
|
||||
|
||||
我们使用 [Weblate](https://hosted.weblate.org/projects/frigate-nvr/) 平台提供翻译支持,欢迎参与进来一起完善。
|
||||
|
||||
|
||||
## 非官方中文讨论社区
|
||||
欢迎加入中文讨论QQ群:[1043861059](https://qm.qq.com/q/7vQKsTmSz)
|
||||
|
||||
欢迎加入中文讨论 QQ 群:[1043861059](https://qm.qq.com/q/7vQKsTmSz)
|
||||
|
||||
Bilibili:https://space.bilibili.com/3546894915602564
|
||||
|
||||
|
||||
## 中文社区赞助商
|
||||
|
||||
[](https://edgeone.ai/zh?from=github)
|
||||
本项目 CDN 加速及安全防护由 Tencent EdgeOne 赞助
|
||||
|
||||
---
|
||||
|
||||
**Copyright © 2025 Frigate LLC.**
|
||||
|
||||
@ -15,7 +15,7 @@ ARG AMDGPU
|
||||
|
||||
RUN apt update -qq && \
|
||||
apt install -y wget gpg && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1/ubuntu/jammy/amdgpu-install_7.1.70100-1_all.deb && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.1.1/ubuntu/jammy/amdgpu-install_7.1.1.70101-1_all.deb && \
|
||||
apt install -y ./rocm.deb && \
|
||||
apt update && \
|
||||
apt install -qq -y rocm
|
||||
|
||||
@ -2,7 +2,7 @@ variable "AMDGPU" {
|
||||
default = "gfx900"
|
||||
}
|
||||
variable "ROCM" {
|
||||
default = "7.1.0"
|
||||
default = "7.1.1"
|
||||
}
|
||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||
default = ""
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
.alert {
|
||||
padding: 12px;
|
||||
background: #fff8e6;
|
||||
border-bottom: 1px solid #ffd166;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.alert a {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
}
|
||||
padding: 12px;
|
||||
background: #fff8e6;
|
||||
border-bottom: 1px solid #ffd166;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .alert {
|
||||
background: #3b2f0b;
|
||||
border-bottom: 1px solid #665c22;
|
||||
}
|
||||
|
||||
.alert a {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { Button } from "../ui/button";
|
||||
import { LuSettings } from "react-icons/lu";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import AutoUpdatingCameraImage from "./AutoUpdatingCameraImage";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -24,7 +24,7 @@ export default function DebugCameraImage({
|
||||
}: DebugCameraImageProps) {
|
||||
const { t } = useTranslation(["components/camera"]);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [options, setOptions] = usePersistence<Options>(
|
||||
const [options, setOptions] = useUserPersistence<Options>(
|
||||
`${cameraConfig?.name}-feed`,
|
||||
emptyObject,
|
||||
);
|
||||
|
||||
@ -13,7 +13,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { VideoPreview } from "../preview/ScrubbablePreview";
|
||||
import { useApiHost } from "@/api";
|
||||
import { isDesktop, isSafari } from "react-device-detect";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { Button } from "../ui/button";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
@ -112,7 +112,7 @@ export function AnimatedEventCard({
|
||||
|
||||
// image behavior
|
||||
|
||||
const [alertVideos, _, alertVideosLoaded] = usePersistence(
|
||||
const [alertVideos, _, alertVideosLoaded] = useUserPersistence(
|
||||
"alertVideos",
|
||||
true,
|
||||
);
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
import { MdHome } from "react-icons/md";
|
||||
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
@ -57,7 +56,7 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as LuIcons from "react-icons/lu";
|
||||
@ -79,6 +78,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { CameraNameLabel } from "../camera/FriendlyNameLabel";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
|
||||
type CameraGroupSelectorProps = {
|
||||
className?: string;
|
||||
@ -109,9 +109,9 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
[timeoutId],
|
||||
);
|
||||
|
||||
// groups
|
||||
// groups - use user-namespaced key for persistence to avoid cross-user conflicts
|
||||
|
||||
const [group, setGroup, , deleteGroup] = usePersistedOverlayState(
|
||||
const [group, setGroup, , deleteGroup] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
);
|
||||
@ -276,7 +276,7 @@ function NewGroupDialog({
|
||||
const [editState, setEditState] = useState<"none" | "add" | "edit">("none");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [, , , deleteGridLayout] = usePersistence(
|
||||
const [, , , deleteGridLayout] = useUserPersistence(
|
||||
`${activeGroup}-draggable-layout`,
|
||||
);
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { SaveSearchDialog } from "./SaveSearchDialog";
|
||||
import { DeleteSearchDialog } from "./DeleteSearchDialog";
|
||||
import {
|
||||
@ -128,9 +128,8 @@ export default function InputWithTags({
|
||||
|
||||
// TODO: search history from browser storage
|
||||
|
||||
const [searchHistory, setSearchHistory, searchHistoryLoaded] = usePersistence<
|
||||
SavedSearchQuery[]
|
||||
>("frigate-search-history");
|
||||
const [searchHistory, setSearchHistory, searchHistoryLoaded] =
|
||||
useUserPersistence<SavedSearchQuery[]>("frigate-search-history");
|
||||
|
||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
@ -5,7 +5,7 @@ import { FaCircle } from "react-icons/fa";
|
||||
import { getUTCOffset } from "@/utils/dateUtil";
|
||||
import { type DayButtonProps, TZDate } from "react-day-picker";
|
||||
import { LAST_24_HOURS_KEY } from "@/types/filter";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
@ -27,7 +27,7 @@ export default function ReviewActivityCalendar({
|
||||
}: ReviewActivityCalendarProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const timezone = useTimezone(config);
|
||||
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
|
||||
const disabledDates = useMemo(() => {
|
||||
const tomorrow = new Date();
|
||||
@ -176,7 +176,7 @@ export function TimezoneAwareCalendar({
|
||||
selectedDay,
|
||||
onSelect,
|
||||
}: TimezoneAwareCalendarProps) {
|
||||
const [weekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
const [weekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
|
||||
const timezoneOffset = useMemo(
|
||||
() =>
|
||||
|
||||
@ -15,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -210,9 +210,9 @@ export default function HlsVideoPlayer({
|
||||
|
||||
const [tallCamera, setTallCamera] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(true);
|
||||
const [muted, setMuted] = usePersistence("hlsPlayerMuted", true);
|
||||
const [muted, setMuted] = useUserPersistence("hlsPlayerMuted", true);
|
||||
const [volume, setVolume] = useOverlayState("playerVolume", 1.0);
|
||||
const [defaultPlaybackRate] = usePersistence("playbackRate", 1);
|
||||
const [defaultPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||
const [playbackRate, setPlaybackRate] = useOverlayState(
|
||||
"playbackRate",
|
||||
defaultPlaybackRate ?? 1,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
@ -72,7 +72,10 @@ function MSEPlayer({
|
||||
const [errorCount, setErrorCount] = useState<number>(0);
|
||||
const totalBytesLoaded = useRef(0);
|
||||
|
||||
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3);
|
||||
const [fallbackTimeout] = useUserPersistence<number>(
|
||||
"liveFallbackTimeout",
|
||||
3,
|
||||
);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
@ -24,7 +24,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
|
||||
import { PiSlidersHorizontalBold } from "react-icons/pi";
|
||||
@ -58,7 +58,7 @@ export default function DetailStream({
|
||||
const effectiveTime = currentTime - annotationOffset / 1000;
|
||||
const [upload, setUpload] = useState<Event | undefined>(undefined);
|
||||
const [controlsExpanded, setControlsExpanded] = useState(false);
|
||||
const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence(
|
||||
const [alwaysExpandActive, setAlwaysExpandActive] = useUserPersistence(
|
||||
"detailStreamActiveExpanded",
|
||||
true,
|
||||
);
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
useContext,
|
||||
} from "react";
|
||||
import { AllGroupsStreamingSettings } from "@/types/frigateConfig";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
|
||||
type StreamingSettingsContextType = {
|
||||
allGroupsStreamingSettings: AllGroupsStreamingSettings;
|
||||
@ -29,7 +29,7 @@ export function StreamingSettingsProvider({
|
||||
persistedGroupStreamingSettings,
|
||||
setPersistedGroupStreamingSettings,
|
||||
isPersistedStreamingSettingsLoaded,
|
||||
] = usePersistence<AllGroupsStreamingSettings>("streaming-settings");
|
||||
] = useUserPersistence<AllGroupsStreamingSettings>("streaming-settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (isPersistedStreamingSettingsLoaded) {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { usePersistence } from "./use-persistence";
|
||||
import { useUserPersistence } from "./use-user-persistence";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
|
||||
export function useOverlayState<S>(
|
||||
key: string,
|
||||
@ -79,6 +81,60 @@ export function usePersistedOverlayState<S extends string>(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Like usePersistedOverlayState, but namespaces the persistence key by username.
|
||||
* This ensures different users on the same browser don't share state.
|
||||
* Automatically migrates data from legacy (non-namespaced) keys on first use.
|
||||
*/
|
||||
export function useUserPersistedOverlayState<S extends string>(
|
||||
key: string,
|
||||
defaultValue: S | undefined = undefined,
|
||||
): [
|
||||
S | undefined,
|
||||
(value: S | undefined, replace?: boolean) => void,
|
||||
boolean,
|
||||
() => void,
|
||||
] {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const currentLocationState = useMemo(() => location.state, [location]);
|
||||
|
||||
// currently selected value from URL state
|
||||
const overlayStateValue = useMemo<S | undefined>(
|
||||
() => location.state && location.state[key],
|
||||
[location, key],
|
||||
);
|
||||
|
||||
// saved value from previous session (user-namespaced with migration)
|
||||
const [persistedValue, setPersistedValue, loaded, deletePersistedValue] =
|
||||
useUserPersistence<S>(key, overlayStateValue);
|
||||
|
||||
const setOverlayStateValue = useCallback(
|
||||
(value: S | undefined, replace: boolean = false) => {
|
||||
setPersistedValue(value);
|
||||
const newLocationState = { ...currentLocationState };
|
||||
newLocationState[key] = value;
|
||||
navigate(location.pathname, { state: newLocationState, replace });
|
||||
},
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[key, currentLocationState, navigate, setPersistedValue],
|
||||
);
|
||||
|
||||
// Don't return a value until auth has finished loading
|
||||
if (auth.isLoading) {
|
||||
return [undefined, setOverlayStateValue, false, deletePersistedValue];
|
||||
}
|
||||
|
||||
return [
|
||||
overlayStateValue ?? persistedValue ?? defaultValue,
|
||||
setOverlayStateValue,
|
||||
loaded,
|
||||
deletePersistedValue,
|
||||
];
|
||||
}
|
||||
|
||||
export function useHashState<S extends string>(): [
|
||||
S | undefined,
|
||||
(value: S) => void,
|
||||
|
||||
199
web/src/hooks/use-user-persistence.ts
Normal file
199
web/src/hooks/use-user-persistence.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import { useEffect, useState, useCallback, useContext, useRef } from "react";
|
||||
import { get as getData, set as setData, del as delData } from "idb-keyval";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
|
||||
type useUserPersistenceReturn<S> = [
|
||||
value: S | undefined,
|
||||
setValue: (value: S | undefined) => void,
|
||||
loaded: boolean,
|
||||
deleteValue: () => void,
|
||||
];
|
||||
|
||||
// Key used to track which keys have been migrated to prevent re-reading old keys
|
||||
const MIGRATED_KEYS_STORAGE_KEY = "frigate-migrated-user-keys";
|
||||
|
||||
/**
|
||||
* Compute the user-namespaced key for a given base key and username.
|
||||
*/
|
||||
export function getUserNamespacedKey(
|
||||
key: string,
|
||||
username: string | undefined,
|
||||
): string {
|
||||
const isAuthenticated = username && username !== "anonymous";
|
||||
return isAuthenticated ? `${key}:${username}` : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user-namespaced key from storage.
|
||||
* This is useful for clearing user-specific data from settings pages.
|
||||
*/
|
||||
export async function deleteUserNamespacedKey(
|
||||
key: string,
|
||||
username: string | undefined,
|
||||
): Promise<void> {
|
||||
const namespacedKey = getUserNamespacedKey(key, username);
|
||||
await delData(namespacedKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of keys that have already been migrated for a specific user.
|
||||
*/
|
||||
async function getMigratedKeys(username: string): Promise<Set<string>> {
|
||||
const allMigrated =
|
||||
(await getData<Record<string, string[]>>(MIGRATED_KEYS_STORAGE_KEY)) || {};
|
||||
return new Set(allMigrated[username] || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a key as migrated for a specific user.
|
||||
*/
|
||||
async function markKeyAsMigrated(username: string, key: string): Promise<void> {
|
||||
const allMigrated =
|
||||
(await getData<Record<string, string[]>>(MIGRATED_KEYS_STORAGE_KEY)) || {};
|
||||
const userMigrated = new Set(allMigrated[username] || []);
|
||||
userMigrated.add(key);
|
||||
allMigrated[username] = Array.from(userMigrated);
|
||||
await setData(MIGRATED_KEYS_STORAGE_KEY, allMigrated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for user-namespaced persistence with automatic migration from legacy keys.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Namespaces storage keys by username to isolate per-user preferences
|
||||
* 2. Automatically migrates data from legacy (non-namespaced) keys on first use
|
||||
* 3. Tracks migrated keys to prevent re-reading stale data after migration
|
||||
* 4. Waits for auth to load before returning values to prevent race conditions
|
||||
*
|
||||
* @param key - The base key name (will be namespaced with username)
|
||||
* @param defaultValue - Default value if no persisted value exists
|
||||
*/
|
||||
export function useUserPersistence<S>(
|
||||
key: string,
|
||||
defaultValue: S | undefined = undefined,
|
||||
): useUserPersistenceReturn<S> {
|
||||
const { auth } = useContext(AuthContext);
|
||||
const [value, setInternalValue] = useState<S | undefined>(defaultValue);
|
||||
const [loaded, setLoaded] = useState<boolean>(false);
|
||||
const migrationAttemptedRef = useRef(false);
|
||||
|
||||
// Compute the user-namespaced key
|
||||
const username = auth?.user?.username;
|
||||
const isAuthenticated =
|
||||
username && username !== "anonymous" && !auth.isLoading;
|
||||
const namespacedKey = isAuthenticated ? `${key}:${username}` : key;
|
||||
|
||||
// Track the key that was used when loading to prevent cross-key writes
|
||||
const loadedKeyRef = useRef<string | null>(null);
|
||||
|
||||
const setValue = useCallback(
|
||||
(newValue: S | undefined) => {
|
||||
// Only allow writes if we've loaded for this key
|
||||
// This prevents stale callbacks from writing to the wrong key
|
||||
if (loadedKeyRef.current !== namespacedKey) {
|
||||
return;
|
||||
}
|
||||
setInternalValue(newValue);
|
||||
async function update() {
|
||||
await setData(namespacedKey, newValue);
|
||||
}
|
||||
update();
|
||||
},
|
||||
[namespacedKey],
|
||||
);
|
||||
|
||||
const deleteValue = useCallback(async () => {
|
||||
if (loadedKeyRef.current !== namespacedKey) {
|
||||
return;
|
||||
}
|
||||
await delData(namespacedKey);
|
||||
setInternalValue(defaultValue);
|
||||
}, [namespacedKey, defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't load until auth is resolved
|
||||
if (auth.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset state when key changes - this prevents stale writes
|
||||
loadedKeyRef.current = null;
|
||||
migrationAttemptedRef.current = false;
|
||||
setLoaded(false);
|
||||
|
||||
async function loadWithMigration() {
|
||||
// For authenticated users, check if we need to migrate from legacy key
|
||||
if (isAuthenticated && username && !migrationAttemptedRef.current) {
|
||||
migrationAttemptedRef.current = true;
|
||||
|
||||
const migratedKeys = await getMigratedKeys(username);
|
||||
|
||||
// Check if we already have data in the namespaced key
|
||||
const existingNamespacedValue = await getData<S>(namespacedKey);
|
||||
|
||||
if (typeof existingNamespacedValue !== "undefined") {
|
||||
// Already have namespaced data, use it
|
||||
setInternalValue(existingNamespacedValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this key has already been migrated (even if value was deleted)
|
||||
if (migratedKeys.has(key)) {
|
||||
// Already migrated, don't read from legacy key
|
||||
setInternalValue(defaultValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to migrate from legacy key
|
||||
const legacyValue = await getData<S>(key);
|
||||
if (typeof legacyValue !== "undefined") {
|
||||
// Migrate: copy to namespaced key, delete legacy key, mark as migrated
|
||||
await setData(namespacedKey, legacyValue);
|
||||
await delData(key);
|
||||
await markKeyAsMigrated(username, key);
|
||||
setInternalValue(legacyValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// No legacy value, just mark as migrated so we don't check again
|
||||
await markKeyAsMigrated(username, key);
|
||||
setInternalValue(defaultValue);
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// For unauthenticated users or after migration check, just load normally
|
||||
const storedValue = await getData<S>(namespacedKey);
|
||||
if (typeof storedValue !== "undefined") {
|
||||
setInternalValue(storedValue);
|
||||
} else {
|
||||
setInternalValue(defaultValue);
|
||||
}
|
||||
loadedKeyRef.current = namespacedKey;
|
||||
setLoaded(true);
|
||||
}
|
||||
|
||||
loadWithMigration();
|
||||
}, [
|
||||
auth.isLoading,
|
||||
isAuthenticated,
|
||||
username,
|
||||
key,
|
||||
namespacedKey,
|
||||
defaultValue,
|
||||
]);
|
||||
|
||||
// Don't return a value until auth has finished loading
|
||||
if (auth.isLoading) {
|
||||
return [undefined, setValue, false, deleteValue];
|
||||
}
|
||||
|
||||
return [value, setValue, loaded, deleteValue];
|
||||
}
|
||||
@ -3,7 +3,7 @@ import useApiFilter from "@/hooks/use-api-filter";
|
||||
import { useCameraPreviews } from "@/hooks/use-camera-previews";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import {
|
||||
@ -42,7 +42,10 @@ export default function Events() {
|
||||
"alert",
|
||||
);
|
||||
|
||||
const [showReviewed, setShowReviewed] = usePersistence("showReviewed", false);
|
||||
const [showReviewed, setShowReviewed] = useUserPersistence(
|
||||
"showReviewed",
|
||||
false,
|
||||
);
|
||||
|
||||
const [recording, setRecording] = useOverlayState<RecordingStartingPoint>(
|
||||
"recording",
|
||||
|
||||
@ -7,7 +7,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar";
|
||||
import { useApiFilterArgs } from "@/hooks/use-api-filter";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { SearchFilter, SearchQuery, SearchResult } from "@/types/search";
|
||||
import { ModelState } from "@/types/ws";
|
||||
@ -47,7 +47,10 @@ export default function Explore() {
|
||||
|
||||
// grid
|
||||
|
||||
const [columnCount, setColumnCount] = usePersistence("exploreGridColumns", 4);
|
||||
const [columnCount, setColumnCount] = useUserPersistence(
|
||||
"exploreGridColumns",
|
||||
4,
|
||||
);
|
||||
const gridColumns = useMemo(() => {
|
||||
if (isMobileOnly) {
|
||||
return 2;
|
||||
@ -57,7 +60,7 @@ export default function Explore() {
|
||||
|
||||
// default layout
|
||||
|
||||
const [defaultView, setDefaultView, defaultViewLoaded] = usePersistence(
|
||||
const [defaultView, setDefaultView, defaultViewLoaded] = useUserPersistence(
|
||||
"exploreDefaultView",
|
||||
"summary",
|
||||
);
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import {
|
||||
useHashState,
|
||||
usePersistedOverlayState,
|
||||
useSearchEffect,
|
||||
} from "@/hooks/use-overlay-state";
|
||||
import { useHashState, useSearchEffect } from "@/hooks/use-overlay-state";
|
||||
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import LiveBirdseyeView from "@/views/live/LiveBirdseyeView";
|
||||
import LiveCameraView from "@/views/live/LiveCameraView";
|
||||
@ -24,7 +21,7 @@ function Live() {
|
||||
// selection
|
||||
|
||||
const [selectedCameraName, setSelectedCameraName] = useHashState();
|
||||
const [cameraGroup, setCameraGroup, loaded, ,] = usePersistedOverlayState(
|
||||
const [cameraGroup, setCameraGroup, loaded] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
BirdseyeConfig,
|
||||
@ -40,7 +40,7 @@ import { IoClose } from "react-icons/io5";
|
||||
import { LuLayoutDashboard, LuPencil } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EditGroupDialog } from "@/components/filter/CameraGroupSelector";
|
||||
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { useUserPersistedOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { FaCompress, FaExpand } from "react-icons/fa";
|
||||
import {
|
||||
Tooltip,
|
||||
@ -102,8 +102,8 @@ export default function DraggableGridLayout({
|
||||
|
||||
// preferred live modes per camera
|
||||
|
||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
|
||||
|
||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||
useStreamingSettings();
|
||||
@ -118,11 +118,14 @@ export default function DraggableGridLayout({
|
||||
|
||||
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
||||
|
||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] = usePersistence<
|
||||
const [gridLayout, setGridLayout, isGridLayoutLoaded] = useUserPersistence<
|
||||
Layout[]
|
||||
>(`${cameraGroup}-draggable-layout`);
|
||||
|
||||
const [group] = usePersistedOverlayState("cameraGroup", "default" as string);
|
||||
const [group] = useUserPersistedOverlayState(
|
||||
"cameraGroup",
|
||||
"default" as string,
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!config) {
|
||||
@ -142,6 +145,11 @@ export default function DraggableGridLayout({
|
||||
useEffect(() => {
|
||||
setIsEditMode(false);
|
||||
setEditGroup(false);
|
||||
// Reset camera tracking state when group changes to prevent the camera-change
|
||||
// effect from incorrectly overwriting the loaded layout
|
||||
setCurrentCameras(undefined);
|
||||
setCurrentIncludeBirdseye(undefined);
|
||||
setCurrentGridLayout(undefined);
|
||||
}, [cameraGroup, setIsEditMode]);
|
||||
|
||||
// camera state
|
||||
@ -165,104 +173,120 @@ export default function DraggableGridLayout({
|
||||
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
||||
);
|
||||
|
||||
const generateLayout = useCallback(() => {
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraNames =
|
||||
includeBirdseye && birdseyeConfig?.enabled
|
||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||
: cameras.map((camera) => camera?.name || "");
|
||||
|
||||
const optionsMap: Layout[] = currentGridLayout
|
||||
? currentGridLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||
: [];
|
||||
|
||||
cameraNames.forEach((cameraName, index) => {
|
||||
const existingLayout = optionsMap.find(
|
||||
(layout) => layout.i === cameraName,
|
||||
);
|
||||
|
||||
// Skip if the camera already exists in the layout
|
||||
if (existingLayout) {
|
||||
const generateLayout = useCallback(
|
||||
(baseLayout: Layout[] | undefined) => {
|
||||
if (!isGridLayoutLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
let aspectRatio;
|
||||
let col;
|
||||
const cameraNames =
|
||||
includeBirdseye && birdseyeConfig?.enabled
|
||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||
: cameras.map((camera) => camera?.name || "");
|
||||
|
||||
// Handle "birdseye" camera as a special case
|
||||
if (cameraName === "birdseye") {
|
||||
aspectRatio =
|
||||
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
|
||||
col = 0; // Set birdseye camera in the first column
|
||||
} else {
|
||||
const camera = cameras.find((cam) => cam.name === cameraName);
|
||||
aspectRatio =
|
||||
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
|
||||
col = index % 3; // Regular cameras distributed across columns
|
||||
}
|
||||
const optionsMap: Layout[] = baseLayout
|
||||
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||
: [];
|
||||
|
||||
// Calculate layout options based on aspect ratio
|
||||
const columnsPerPlayer = 4;
|
||||
let height;
|
||||
let width;
|
||||
cameraNames.forEach((cameraName, index) => {
|
||||
const existingLayout = optionsMap.find(
|
||||
(layout) => layout.i === cameraName,
|
||||
);
|
||||
|
||||
if (aspectRatio < 1) {
|
||||
// Portrait
|
||||
height = 2 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
} else if (aspectRatio > 2) {
|
||||
// Wide
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = 2 * columnsPerPlayer;
|
||||
} else {
|
||||
// Landscape
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
}
|
||||
// Skip if the camera already exists in the layout
|
||||
if (existingLayout) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
i: cameraName,
|
||||
x: col * width,
|
||||
y: 0, // don't set y, grid does automatically
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
let aspectRatio;
|
||||
let col;
|
||||
|
||||
optionsMap.push(options);
|
||||
});
|
||||
// Handle "birdseye" camera as a special case
|
||||
if (cameraName === "birdseye") {
|
||||
aspectRatio =
|
||||
(birdseyeConfig?.width || 1) / (birdseyeConfig?.height || 1);
|
||||
col = 0; // Set birdseye camera in the first column
|
||||
} else {
|
||||
const camera = cameras.find((cam) => cam.name === cameraName);
|
||||
aspectRatio =
|
||||
(camera && camera?.detect.width / camera?.detect.height) || 16 / 9;
|
||||
col = index % 3; // Regular cameras distributed across columns
|
||||
}
|
||||
|
||||
return optionsMap;
|
||||
}, [
|
||||
cameras,
|
||||
isGridLayoutLoaded,
|
||||
currentGridLayout,
|
||||
includeBirdseye,
|
||||
birdseyeConfig,
|
||||
]);
|
||||
// Calculate layout options based on aspect ratio
|
||||
const columnsPerPlayer = 4;
|
||||
let height;
|
||||
let width;
|
||||
|
||||
if (aspectRatio < 1) {
|
||||
// Portrait
|
||||
height = 2 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
} else if (aspectRatio > 2) {
|
||||
// Wide
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = 2 * columnsPerPlayer;
|
||||
} else {
|
||||
// Landscape
|
||||
height = 1 * columnsPerPlayer;
|
||||
width = columnsPerPlayer;
|
||||
}
|
||||
|
||||
const options = {
|
||||
i: cameraName,
|
||||
x: col * width,
|
||||
y: 0, // don't set y, grid does automatically
|
||||
w: width,
|
||||
h: height,
|
||||
};
|
||||
|
||||
optionsMap.push(options);
|
||||
});
|
||||
|
||||
return optionsMap;
|
||||
},
|
||||
[cameras, isGridLayoutLoaded, includeBirdseye, birdseyeConfig],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isGridLayoutLoaded) {
|
||||
if (gridLayout) {
|
||||
// set current grid layout from loaded
|
||||
setCurrentGridLayout(gridLayout);
|
||||
// set current grid layout from loaded, possibly adding new cameras
|
||||
const updatedLayout = generateLayout(gridLayout);
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
// Only save if cameras were added (layout changed)
|
||||
if (!isEqual(updatedLayout, gridLayout)) {
|
||||
setGridLayout(updatedLayout);
|
||||
}
|
||||
// Set camera tracking state so the camera-change effect has a baseline
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
} else {
|
||||
// idb is empty, set it with an initial layout
|
||||
setGridLayout(generateLayout());
|
||||
const newLayout = generateLayout(undefined);
|
||||
setCurrentGridLayout(newLayout);
|
||||
setGridLayout(newLayout);
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isEditMode,
|
||||
gridLayout,
|
||||
currentGridLayout,
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
generateLayout,
|
||||
cameras,
|
||||
includeBirdseye,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only regenerate layout when cameras change WITHIN an already-loaded group
|
||||
// Skip if currentCameras is undefined (means we just switched groups and
|
||||
// the first useEffect hasn't run yet to set things up)
|
||||
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isEqual(cameras, currentCameras) ||
|
||||
includeBirdseye !== currentIncludeBirdseye
|
||||
@ -270,15 +294,17 @@ export default function DraggableGridLayout({
|
||||
setCurrentCameras(cameras);
|
||||
setCurrentIncludeBirdseye(includeBirdseye);
|
||||
|
||||
// set new grid layout in idb
|
||||
setGridLayout(generateLayout());
|
||||
// Regenerate layout based on current layout, adding any new cameras
|
||||
const updatedLayout = generateLayout(currentGridLayout);
|
||||
setCurrentGridLayout(updatedLayout);
|
||||
setGridLayout(updatedLayout);
|
||||
}
|
||||
}, [
|
||||
cameras,
|
||||
includeBirdseye,
|
||||
currentCameras,
|
||||
currentIncludeBirdseye,
|
||||
setCurrentGridLayout,
|
||||
currentGridLayout,
|
||||
generateLayout,
|
||||
setGridLayout,
|
||||
isGridLayoutLoaded,
|
||||
|
||||
@ -101,7 +101,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import axios from "axios";
|
||||
@ -146,7 +146,7 @@ export default function LiveCameraView({
|
||||
|
||||
// supported features
|
||||
|
||||
const [streamName, setStreamName] = usePersistence<string>(
|
||||
const [streamName, setStreamName] = useUserPersistence<string>(
|
||||
`${camera.name}-stream`,
|
||||
Object.values(camera.live.streams)[0],
|
||||
);
|
||||
@ -279,7 +279,7 @@ export default function LiveCameraView({
|
||||
const [pip, setPip] = useState(false);
|
||||
const [lowBandwidth, setLowBandwidth] = useState(false);
|
||||
|
||||
const [playInBackground, setPlayInBackground] = usePersistence<boolean>(
|
||||
const [playInBackground, setPlayInBackground] = useUserPersistence<boolean>(
|
||||
`${camera.name}-background-play`,
|
||||
false,
|
||||
);
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
CameraConfig,
|
||||
@ -78,7 +78,7 @@ export default function LiveDashboardView({
|
||||
|
||||
// layout
|
||||
|
||||
const [mobileLayout, setMobileLayout] = usePersistence<"grid" | "list">(
|
||||
const [mobileLayout, setMobileLayout] = useUserPersistence<"grid" | "list">(
|
||||
"live-layout",
|
||||
isDesktop ? "grid" : "list",
|
||||
);
|
||||
@ -211,8 +211,8 @@ export default function LiveDashboardView({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||
const [globalAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = useUserPersistence("displayCameraNames", false);
|
||||
|
||||
const { allGroupsStreamingSettings, setAllGroupsStreamingSettings } =
|
||||
useStreamingSettings();
|
||||
|
||||
@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
||||
import useSWR from "swr";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@ -104,7 +104,7 @@ export default function ObjectSettingsView({
|
||||
},
|
||||
];
|
||||
|
||||
const [options, setOptions, optionsLoaded] = usePersistence<Options>(
|
||||
const [options, setOptions, optionsLoaded] = useUserPersistence<Options>(
|
||||
`${selectedCamera}-feed`,
|
||||
emptyObject,
|
||||
);
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useContext, useEffect } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { toast } from "sonner";
|
||||
import { Separator } from "../../components/ui/separator";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { del as delData } from "idb-keyval";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
useUserPersistence,
|
||||
deleteUserNamespacedKey,
|
||||
} from "@/hooks/use-user-persistence";
|
||||
import { isSafari } from "react-device-detect";
|
||||
import {
|
||||
Select,
|
||||
@ -19,6 +21,7 @@ import {
|
||||
SelectTrigger,
|
||||
} from "../../components/ui/select";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthContext } from "@/context/auth-context";
|
||||
|
||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||
@ -26,13 +29,16 @@ const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||
export default function UiSettingsView() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { auth } = useContext(AuthContext);
|
||||
const username = auth?.user?.username;
|
||||
|
||||
const clearStoredLayouts = useCallback(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Object.entries(config.camera_groups).forEach(async (value) => {
|
||||
await delData(`${value[0]}-draggable-layout`)
|
||||
await deleteUserNamespacedKey(`${value[0]}-draggable-layout`, username)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
t("general.toast.success.clearStoredLayout", {
|
||||
@ -56,14 +62,14 @@ export default function UiSettingsView() {
|
||||
);
|
||||
});
|
||||
});
|
||||
}, [config, t]);
|
||||
}, [config, t, username]);
|
||||
|
||||
const clearStreamingSettings = useCallback(async () => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await delData(`streaming-settings`)
|
||||
await deleteUserNamespacedKey(`streaming-settings`, username)
|
||||
.then(() => {
|
||||
toast.success(t("general.toast.success.clearStreamingSettings"), {
|
||||
position: "top-center",
|
||||
@ -83,7 +89,7 @@ export default function UiSettingsView() {
|
||||
},
|
||||
);
|
||||
});
|
||||
}, [config, t]);
|
||||
}, [config, t, username]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.general");
|
||||
@ -91,15 +97,15 @@ export default function UiSettingsView() {
|
||||
|
||||
// settings
|
||||
|
||||
const [autoLive, setAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [cameraNames, setCameraName] = usePersistence(
|
||||
const [autoLive, setAutoLive] = useUserPersistence("autoLiveView", true);
|
||||
const [cameraNames, setCameraName] = useUserPersistence(
|
||||
"displayCameraNames",
|
||||
false,
|
||||
);
|
||||
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
|
||||
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
|
||||
const [fallbackTimeout, setFallbackTimeout] = usePersistence(
|
||||
const [playbackRate, setPlaybackRate] = useUserPersistence("playbackRate", 1);
|
||||
const [weekStartsOn, setWeekStartsOn] = useUserPersistence("weekStartsOn", 0);
|
||||
const [alertVideos, setAlertVideos] = useUserPersistence("alertVideos", true);
|
||||
const [fallbackTimeout, setFallbackTimeout] = useUserPersistence(
|
||||
"liveFallbackTimeout",
|
||||
3,
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user