mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 21:44:13 +03:00
fix layout race condition
This commit is contained in:
parent
2d6e23fe6d
commit
581aae3356
@ -12,6 +12,29 @@ type useUserPersistenceReturn<S> = [
|
|||||||
// Key used to track which keys have been migrated to prevent re-reading old keys
|
// Key used to track which keys have been migrated to prevent re-reading old keys
|
||||||
const MIGRATED_KEYS_STORAGE_KEY = "frigate-migrated-user-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.
|
* Get the set of keys that have already been migrated for a specific user.
|
||||||
*/
|
*/
|
||||||
@ -60,8 +83,16 @@ export function useUserPersistence<S>(
|
|||||||
username && username !== "anonymous" && !auth.isLoading;
|
username && username !== "anonymous" && !auth.isLoading;
|
||||||
const namespacedKey = isAuthenticated ? `${key}:${username}` : key;
|
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(
|
const setValue = useCallback(
|
||||||
(newValue: S | undefined) => {
|
(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);
|
setInternalValue(newValue);
|
||||||
async function update() {
|
async function update() {
|
||||||
await setData(namespacedKey, newValue);
|
await setData(namespacedKey, newValue);
|
||||||
@ -72,6 +103,9 @@ export function useUserPersistence<S>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const deleteValue = useCallback(async () => {
|
const deleteValue = useCallback(async () => {
|
||||||
|
if (loadedKeyRef.current !== namespacedKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await delData(namespacedKey);
|
await delData(namespacedKey);
|
||||||
setInternalValue(defaultValue);
|
setInternalValue(defaultValue);
|
||||||
}, [namespacedKey, defaultValue]);
|
}, [namespacedKey, defaultValue]);
|
||||||
@ -82,7 +116,8 @@ export function useUserPersistence<S>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset migration flag when key changes
|
// Reset state when key changes - this prevents stale writes
|
||||||
|
loadedKeyRef.current = null;
|
||||||
migrationAttemptedRef.current = false;
|
migrationAttemptedRef.current = false;
|
||||||
setLoaded(false);
|
setLoaded(false);
|
||||||
|
|
||||||
@ -99,6 +134,7 @@ export function useUserPersistence<S>(
|
|||||||
if (typeof existingNamespacedValue !== "undefined") {
|
if (typeof existingNamespacedValue !== "undefined") {
|
||||||
// Already have namespaced data, use it
|
// Already have namespaced data, use it
|
||||||
setInternalValue(existingNamespacedValue);
|
setInternalValue(existingNamespacedValue);
|
||||||
|
loadedKeyRef.current = namespacedKey;
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -107,6 +143,7 @@ export function useUserPersistence<S>(
|
|||||||
if (migratedKeys.has(key)) {
|
if (migratedKeys.has(key)) {
|
||||||
// Already migrated, don't read from legacy key
|
// Already migrated, don't read from legacy key
|
||||||
setInternalValue(defaultValue);
|
setInternalValue(defaultValue);
|
||||||
|
loadedKeyRef.current = namespacedKey;
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -119,6 +156,7 @@ export function useUserPersistence<S>(
|
|||||||
await delData(key);
|
await delData(key);
|
||||||
await markKeyAsMigrated(username, key);
|
await markKeyAsMigrated(username, key);
|
||||||
setInternalValue(legacyValue);
|
setInternalValue(legacyValue);
|
||||||
|
loadedKeyRef.current = namespacedKey;
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -126,6 +164,7 @@ export function useUserPersistence<S>(
|
|||||||
// No legacy value, just mark as migrated so we don't check again
|
// No legacy value, just mark as migrated so we don't check again
|
||||||
await markKeyAsMigrated(username, key);
|
await markKeyAsMigrated(username, key);
|
||||||
setInternalValue(defaultValue);
|
setInternalValue(defaultValue);
|
||||||
|
loadedKeyRef.current = namespacedKey;
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -137,6 +176,7 @@ export function useUserPersistence<S>(
|
|||||||
} else {
|
} else {
|
||||||
setInternalValue(defaultValue);
|
setInternalValue(defaultValue);
|
||||||
}
|
}
|
||||||
|
loadedKeyRef.current = namespacedKey;
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -145,6 +145,11 @@ export default function DraggableGridLayout({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEditMode(false);
|
setIsEditMode(false);
|
||||||
setEditGroup(false);
|
setEditGroup(false);
|
||||||
|
// Reset camera tracking state when group changes to prevent the camera-change
|
||||||
|
// effect from incorrectly overwriting the loaded layout
|
||||||
|
setCurrentCameras(undefined);
|
||||||
|
setCurrentIncludeBirdseye(undefined);
|
||||||
|
setCurrentGridLayout(undefined);
|
||||||
}, [cameraGroup, setIsEditMode]);
|
}, [cameraGroup, setIsEditMode]);
|
||||||
|
|
||||||
// camera state
|
// camera state
|
||||||
@ -168,104 +173,120 @@ export default function DraggableGridLayout({
|
|||||||
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
[setGridLayout, isGridLayoutLoaded, gridLayout, currentGridLayout],
|
||||||
);
|
);
|
||||||
|
|
||||||
const generateLayout = useCallback(() => {
|
const generateLayout = useCallback(
|
||||||
if (!isGridLayoutLoaded) {
|
(baseLayout: Layout[] | undefined) => {
|
||||||
return;
|
if (!isGridLayoutLoaded) {
|
||||||
}
|
|
||||||
|
|
||||||
const cameraNames =
|
|
||||||
includeBirdseye && birdseyeConfig?.enabled
|
|
||||||
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
|
||||||
: cameras.map((camera) => camera?.name || "");
|
|
||||||
|
|
||||||
const optionsMap: Layout[] = currentGridLayout
|
|
||||||
? currentGridLayout.filter((layout) => cameraNames?.includes(layout.i))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
cameraNames.forEach((cameraName, index) => {
|
|
||||||
const existingLayout = optionsMap.find(
|
|
||||||
(layout) => layout.i === cameraName,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Skip if the camera already exists in the layout
|
|
||||||
if (existingLayout) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let aspectRatio;
|
const cameraNames =
|
||||||
let col;
|
includeBirdseye && birdseyeConfig?.enabled
|
||||||
|
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
|
||||||
|
: cameras.map((camera) => camera?.name || "");
|
||||||
|
|
||||||
// Handle "birdseye" camera as a special case
|
const optionsMap: Layout[] = baseLayout
|
||||||
if (cameraName === "birdseye") {
|
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate layout options based on aspect ratio
|
cameraNames.forEach((cameraName, index) => {
|
||||||
const columnsPerPlayer = 4;
|
const existingLayout = optionsMap.find(
|
||||||
let height;
|
(layout) => layout.i === cameraName,
|
||||||
let width;
|
);
|
||||||
|
|
||||||
if (aspectRatio < 1) {
|
// Skip if the camera already exists in the layout
|
||||||
// Portrait
|
if (existingLayout) {
|
||||||
height = 2 * columnsPerPlayer;
|
return;
|
||||||
width = columnsPerPlayer;
|
}
|
||||||
} else if (aspectRatio > 2) {
|
|
||||||
// Wide
|
|
||||||
height = 1 * columnsPerPlayer;
|
|
||||||
width = 2 * columnsPerPlayer;
|
|
||||||
} else {
|
|
||||||
// Landscape
|
|
||||||
height = 1 * columnsPerPlayer;
|
|
||||||
width = columnsPerPlayer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
let aspectRatio;
|
||||||
i: cameraName,
|
let col;
|
||||||
x: col * width,
|
|
||||||
y: 0, // don't set y, grid does automatically
|
|
||||||
w: width,
|
|
||||||
h: height,
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
// Calculate layout options based on aspect ratio
|
||||||
}, [
|
const columnsPerPlayer = 4;
|
||||||
cameras,
|
let height;
|
||||||
isGridLayoutLoaded,
|
let width;
|
||||||
currentGridLayout,
|
|
||||||
includeBirdseye,
|
if (aspectRatio < 1) {
|
||||||
birdseyeConfig,
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (isGridLayoutLoaded) {
|
if (isGridLayoutLoaded) {
|
||||||
if (gridLayout) {
|
if (gridLayout) {
|
||||||
// set current grid layout from loaded
|
// set current grid layout from loaded, possibly adding new cameras
|
||||||
setCurrentGridLayout(gridLayout);
|
const updatedLayout = generateLayout(gridLayout);
|
||||||
|
setCurrentGridLayout(updatedLayout);
|
||||||
|
// Only save if cameras were added (layout changed)
|
||||||
|
if (!isEqual(updatedLayout, gridLayout)) {
|
||||||
|
setGridLayout(updatedLayout);
|
||||||
|
}
|
||||||
|
// Set camera tracking state so the camera-change effect has a baseline
|
||||||
|
setCurrentCameras(cameras);
|
||||||
|
setCurrentIncludeBirdseye(includeBirdseye);
|
||||||
} else {
|
} else {
|
||||||
// idb is empty, set it with an initial layout
|
// idb is empty, set it with an initial layout
|
||||||
setGridLayout(generateLayout());
|
const newLayout = generateLayout(undefined);
|
||||||
|
setCurrentGridLayout(newLayout);
|
||||||
|
setGridLayout(newLayout);
|
||||||
|
setCurrentCameras(cameras);
|
||||||
|
setCurrentIncludeBirdseye(includeBirdseye);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isEditMode,
|
|
||||||
gridLayout,
|
gridLayout,
|
||||||
currentGridLayout,
|
|
||||||
setGridLayout,
|
setGridLayout,
|
||||||
isGridLayoutLoaded,
|
isGridLayoutLoaded,
|
||||||
generateLayout,
|
generateLayout,
|
||||||
|
cameras,
|
||||||
|
includeBirdseye,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only regenerate layout when cameras change WITHIN an already-loaded group
|
||||||
|
// Skip if currentCameras is undefined (means we just switched groups and
|
||||||
|
// the first useEffect hasn't run yet to set things up)
|
||||||
|
if (!isGridLayoutLoaded || currentCameras === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isEqual(cameras, currentCameras) ||
|
!isEqual(cameras, currentCameras) ||
|
||||||
includeBirdseye !== currentIncludeBirdseye
|
includeBirdseye !== currentIncludeBirdseye
|
||||||
@ -273,15 +294,17 @@ export default function DraggableGridLayout({
|
|||||||
setCurrentCameras(cameras);
|
setCurrentCameras(cameras);
|
||||||
setCurrentIncludeBirdseye(includeBirdseye);
|
setCurrentIncludeBirdseye(includeBirdseye);
|
||||||
|
|
||||||
// set new grid layout in idb
|
// Regenerate layout based on current layout, adding any new cameras
|
||||||
setGridLayout(generateLayout());
|
const updatedLayout = generateLayout(currentGridLayout);
|
||||||
|
setCurrentGridLayout(updatedLayout);
|
||||||
|
setGridLayout(updatedLayout);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
cameras,
|
cameras,
|
||||||
includeBirdseye,
|
includeBirdseye,
|
||||||
currentCameras,
|
currentCameras,
|
||||||
currentIncludeBirdseye,
|
currentIncludeBirdseye,
|
||||||
setCurrentGridLayout,
|
currentGridLayout,
|
||||||
generateLayout,
|
generateLayout,
|
||||||
setGridLayout,
|
setGridLayout,
|
||||||
isGridLayoutLoaded,
|
isGridLayoutLoaded,
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useContext, useEffect } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Separator } from "../../components/ui/separator";
|
import { Separator } from "../../components/ui/separator";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { del as delData } from "idb-keyval";
|
import {
|
||||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
useUserPersistence,
|
||||||
|
deleteUserNamespacedKey,
|
||||||
|
} from "@/hooks/use-user-persistence";
|
||||||
import { isSafari } from "react-device-detect";
|
import { isSafari } from "react-device-detect";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@ -19,6 +21,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "../../components/ui/select";
|
} from "../../components/ui/select";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AuthContext } from "@/context/auth-context";
|
||||||
|
|
||||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||||
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
||||||
@ -26,13 +29,16 @@ const WEEK_STARTS_ON = ["Sunday", "Monday"];
|
|||||||
export default function UiSettingsView() {
|
export default function UiSettingsView() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const { t } = useTranslation("views/settings");
|
const { t } = useTranslation("views/settings");
|
||||||
|
const { auth } = useContext(AuthContext);
|
||||||
|
const username = auth?.user?.username;
|
||||||
|
|
||||||
const clearStoredLayouts = useCallback(() => {
|
const clearStoredLayouts = useCallback(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(config.camera_groups).forEach(async (value) => {
|
Object.entries(config.camera_groups).forEach(async (value) => {
|
||||||
await delData(`${value[0]}-draggable-layout`)
|
await deleteUserNamespacedKey(`${value[0]}-draggable-layout`, username)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("general.toast.success.clearStoredLayout", {
|
t("general.toast.success.clearStoredLayout", {
|
||||||
@ -56,14 +62,14 @@ export default function UiSettingsView() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [config, t]);
|
}, [config, t, username]);
|
||||||
|
|
||||||
const clearStreamingSettings = useCallback(async () => {
|
const clearStreamingSettings = useCallback(async () => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
await delData(`streaming-settings`)
|
await deleteUserNamespacedKey(`streaming-settings`, username)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(t("general.toast.success.clearStreamingSettings"), {
|
toast.success(t("general.toast.success.clearStreamingSettings"), {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
@ -83,7 +89,7 @@ export default function UiSettingsView() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [config, t]);
|
}, [config, t, username]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("documentTitle.general");
|
document.title = t("documentTitle.general");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user