Use user-namespaced keys for idb persistence (#21110)

* add new hooks

* use new hooks for user based keys

* fix layout race condition
This commit is contained in:
Josh Hawkins 2025-12-01 07:59:54 -06:00 committed by GitHub
parent 97b29d177a
commit 24766ce427
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 433 additions and 141 deletions

View File

@ -5,7 +5,7 @@ import { Button } from "../ui/button";
import { LuSettings } from "react-icons/lu";
import { useCallback, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { 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,
);

View File

@ -13,7 +13,7 @@ import { baseUrl } from "@/api/baseUrl";
import { VideoPreview } from "../preview/ScrubbablePreview";
import { useApiHost } from "@/api";
import { isDesktop, isSafari } from "react-device-detect";
import { 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,
);

View File

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

View File

@ -37,7 +37,7 @@ import {
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { 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);

View File

@ -5,7 +5,7 @@ import { FaCircle } from "react-icons/fa";
import { getUTCOffset } from "@/utils/dateUtil";
import { type DayButtonProps, TZDate } from "react-day-picker";
import { LAST_24_HOURS_KEY } from "@/types/filter";
import { 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(
() =>

View File

@ -15,7 +15,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { AxiosResponse } from "axios";
import { toast } from "sonner";
import { useOverlayState } from "@/hooks/use-overlay-state";
import { 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,

View File

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

View File

@ -24,7 +24,7 @@ import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { Link } from "react-router-dom";
import { Switch } from "@/components/ui/switch";
import { 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,
);

View File

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

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

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

View File

@ -7,7 +7,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator";
import AnimatedCircularProgressBar from "@/components/ui/circular-progress-bar";
import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { useTimezone } from "@/hooks/use-date-utils";
import { 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",
);

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
import useSWR from "swr";
import Heading from "@/components/ui/heading";
import { Switch } from "@/components/ui/switch";
import { 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,
);

View File

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