diff --git a/web/src/components/camera/DebugCameraImage.tsx b/web/src/components/camera/DebugCameraImage.tsx index bc3b6a8c3..924eb86a5 100644 --- a/web/src/components/camera/DebugCameraImage.tsx +++ b/web/src/components/camera/DebugCameraImage.tsx @@ -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( + const [options, setOptions] = useUserPersistence( `${cameraConfig?.name}-feed`, emptyObject, ); diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index b0773eba0..a67dd8305 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -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, ); diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 295477b2a..14845fdb8 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -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`, ); diff --git a/web/src/components/input/InputWithTags.tsx b/web/src/components/input/InputWithTags.tsx index 58098743b..298537136 100755 --- a/web/src/components/input/InputWithTags.tsx +++ b/web/src/components/input/InputWithTags.tsx @@ -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("frigate-search-history"); const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); diff --git a/web/src/components/overlay/ReviewActivityCalendar.tsx b/web/src/components/overlay/ReviewActivityCalendar.tsx index 10617d3c9..86bc424ba 100644 --- a/web/src/components/overlay/ReviewActivityCalendar.tsx +++ b/web/src/components/overlay/ReviewActivityCalendar.tsx @@ -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("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( () => diff --git a/web/src/components/player/HlsVideoPlayer.tsx b/web/src/components/player/HlsVideoPlayer.tsx index 4d7068204..045b8366f 100644 --- a/web/src/components/player/HlsVideoPlayer.tsx +++ b/web/src/components/player/HlsVideoPlayer.tsx @@ -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, diff --git a/web/src/components/player/MsePlayer.tsx b/web/src/components/player/MsePlayer.tsx index b7d1aab01..e643530a4 100644 --- a/web/src/components/player/MsePlayer.tsx +++ b/web/src/components/player/MsePlayer.tsx @@ -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(0); const totalBytesLoaded = useRef(0); - const [fallbackTimeout] = usePersistence("liveFallbackTimeout", 3); + const [fallbackTimeout] = useUserPersistence( + "liveFallbackTimeout", + 3, + ); const videoRef = useRef(null); const wsRef = useRef(null); diff --git a/web/src/components/timeline/DetailStream.tsx b/web/src/components/timeline/DetailStream.tsx index be01eb16e..ec2bc3b27 100644 --- a/web/src/components/timeline/DetailStream.tsx +++ b/web/src/components/timeline/DetailStream.tsx @@ -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(undefined); const [controlsExpanded, setControlsExpanded] = useState(false); - const [alwaysExpandActive, setAlwaysExpandActive] = usePersistence( + const [alwaysExpandActive, setAlwaysExpandActive] = useUserPersistence( "detailStreamActiveExpanded", true, ); diff --git a/web/src/context/streaming-settings-provider.tsx b/web/src/context/streaming-settings-provider.tsx index 82558722e..bcf6da9c5 100644 --- a/web/src/context/streaming-settings-provider.tsx +++ b/web/src/context/streaming-settings-provider.tsx @@ -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("streaming-settings"); + ] = useUserPersistence("streaming-settings"); useEffect(() => { if (isPersistedStreamingSettingsLoaded) { diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index ab4b7fcfe..34389c5cf 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -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( key: string, @@ -79,6 +81,60 @@ export function usePersistedOverlayState( ]; } +/** + * 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( + 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( + () => location.state && location.state[key], + [location, key], + ); + + // saved value from previous session (user-namespaced with migration) + const [persistedValue, setPersistedValue, loaded, deletePersistedValue] = + useUserPersistence(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 | undefined, (value: S) => void, diff --git a/web/src/hooks/use-user-persistence.ts b/web/src/hooks/use-user-persistence.ts new file mode 100644 index 000000000..519a8aa5a --- /dev/null +++ b/web/src/hooks/use-user-persistence.ts @@ -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 = [ + 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 { + 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> { + const allMigrated = + (await getData>(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 { + const allMigrated = + (await getData>(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( + key: string, + defaultValue: S | undefined = undefined, +): useUserPersistenceReturn { + const { auth } = useContext(AuthContext); + const [value, setInternalValue] = useState(defaultValue); + const [loaded, setLoaded] = useState(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(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(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(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(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]; +} diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index e9ec25d2b..a9867cf58 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -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( "recording", diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 9acc066ba..53ebd0401 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -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", ); diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index 18ec7f469..a8777aec7 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -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, ); diff --git a/web/src/views/live/DraggableGridLayout.tsx b/web/src/views/live/DraggableGridLayout.tsx index 6b1985bd7..5b875ae5c 100644 --- a/web/src/views/live/DraggableGridLayout.tsx +++ b/web/src/views/live/DraggableGridLayout.tsx @@ -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, diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index ada72bee3..a6b0beb4b 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -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( + const [streamName, setStreamName] = useUserPersistence( `${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( + const [playInBackground, setPlayInBackground] = useUserPersistence( `${camera.name}-background-play`, false, ); diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index dcaedc87a..50179fd9b 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -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(); diff --git a/web/src/views/settings/ObjectSettingsView.tsx b/web/src/views/settings/ObjectSettingsView.tsx index 53ad85efa..836cb3cc3 100644 --- a/web/src/views/settings/ObjectSettingsView.tsx +++ b/web/src/views/settings/ObjectSettingsView.tsx @@ -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( + const [options, setOptions, optionsLoaded] = useUserPersistence( `${selectedCamera}-feed`, emptyObject, ); diff --git a/web/src/views/settings/UiSettingsView.tsx b/web/src/views/settings/UiSettingsView.tsx index 34df0ddc8..717e3a3af 100644 --- a/web/src/views/settings/UiSettingsView.tsx +++ b/web/src/views/settings/UiSettingsView.tsx @@ -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("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, );