mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-01 16:42:18 +03:00
Miscellaneous fixes (#23238)
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
* start audio transcription post processor when enabled on any camera * Fetch embed key whenever an error occurs in case the llama server was restarted * mypy * add tooltips for colored dots in settings menu * add ability to reorder cameras from management pane * add ability to reorder birdseye * add reordering save text to camera management view * Include NPU in latency performance hint * Implement turbo for NPU on object detection * hide order fields * drop auto-derived field paths from camera value when unset globally * use correct field type for export hwaccel args * add debug replay to detail actions menu * clarify debug replay in docs * guard get_current_frame_time against missing camera state * Implement debug reply from export * Refactor debug replay to use sources for dynamic playback * Mypy * fix debug export replay source timestamp handling * skip replay cameras in stats immediately * broadcast debug replay state over ws and buffer pre-OPEN sends - push debug replay session state over the job_state ws topic so the status bar reacts instantly to start/stop without polling - fix child-effect-before-parent-effect race in WsProvider that silently dropped initial snapshot requests on cold load * fix debug replay test hang --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
co-authored by
Nicolas Mowen
parent
d968f00500
commit
43d97acd21
@@ -222,7 +222,7 @@
|
||||
"label": "Hide object path"
|
||||
},
|
||||
"debugReplay": {
|
||||
"label": "Debug replay",
|
||||
"label": "Debug Replay",
|
||||
"aria": "View this tracked object in the debug replay view"
|
||||
},
|
||||
"more": {
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
"profilePrefix": "{{profile}} profile: {{fields}}"
|
||||
}
|
||||
},
|
||||
"menuDot": {
|
||||
"overrideGlobal": "This section overrides the global configuration",
|
||||
"overrideProfile": "This section is overridden by the {{profile}} profile",
|
||||
"unsaved": "This section has unsaved changes"
|
||||
},
|
||||
"menu": {
|
||||
"general": "General",
|
||||
"globalConfig": "Global configuration",
|
||||
@@ -472,10 +477,13 @@
|
||||
"streams": {
|
||||
"title": "Enable / Disable Cameras",
|
||||
"enableLabel": "Enabled cameras",
|
||||
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>",
|
||||
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em><br /><br />Drag the handle to reorder the cameras as they appear in the UI. The order of enabled cameras will be reflected throughout the UI including the Live dashboard and camera selection dropdowns.",
|
||||
"disableLabel": "Disabled cameras",
|
||||
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
|
||||
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
|
||||
"reorderHandle": "Drag to reorder",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved",
|
||||
"friendlyName": {
|
||||
"edit": "Edit camera display name",
|
||||
"title": "Edit Display Name",
|
||||
@@ -1682,6 +1690,13 @@
|
||||
"objects": "Objects",
|
||||
"motion": "Motion",
|
||||
"continuous": "Continuous"
|
||||
},
|
||||
"cameraOrder": {
|
||||
"label": "Camera order",
|
||||
"description": "Drag cameras to set their order in the Birdseye layout.",
|
||||
"reorderHandle": "Drag to reorder",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved"
|
||||
}
|
||||
},
|
||||
"retainMode": {
|
||||
|
||||
@@ -10,15 +10,21 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttempt = useRef(0);
|
||||
const unmounted = useRef(false);
|
||||
const pendingSends = useRef<Map<string, unknown>>(new Map());
|
||||
|
||||
const sendJsonMessage = useCallback((msg: unknown) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
} else if (msg && typeof msg === "object" && "topic" in msg) {
|
||||
// Sends issued before the socket reaches OPEN (or during a reconnect
|
||||
// window) are buffered here and flushed in onopen
|
||||
pendingSends.current.set(String((msg as { topic: unknown }).topic), msg);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
const queue = pendingSends.current;
|
||||
|
||||
function connect() {
|
||||
if (unmounted.current) return;
|
||||
@@ -31,6 +37,10 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
||||
ws.send(
|
||||
JSON.stringify({ topic: "onConnect", message: "", retain: false }),
|
||||
);
|
||||
for (const queued of queue.values()) {
|
||||
ws.send(JSON.stringify(queued));
|
||||
}
|
||||
queue.clear();
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
@@ -64,6 +74,7 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
}
|
||||
queue.clear();
|
||||
resetWsStore();
|
||||
};
|
||||
}, [wsUrl]);
|
||||
|
||||
@@ -32,6 +32,9 @@ import { FaFolder, FaVideo } from "react-icons/fa";
|
||||
import { HiSquare2Stack } from "react-icons/hi2";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import useContextMenu from "@/hooks/use-contextmenu";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type CaseCardProps = {
|
||||
className: string;
|
||||
@@ -123,11 +126,63 @@ export function ExportCard({
|
||||
onAssignToCase,
|
||||
onRemoveFromCase,
|
||||
}: ExportCardProps) {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
const { t } = useTranslation(["views/exports", "views/replay"]);
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = useIsAdmin();
|
||||
const [loading, setLoading] = useState(
|
||||
exportedRecording.thumb_path.length > 0,
|
||||
);
|
||||
const [isStartingReplay, setIsStartingReplay] = useState(false);
|
||||
|
||||
const handleDebugReplay = useCallback(() => {
|
||||
setIsStartingReplay(true);
|
||||
|
||||
axios
|
||||
.post("debug_replay/start_from_export", {
|
||||
export_id: exportedRecording.id,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
navigate("/replay");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
|
||||
if (error.response?.status === 409) {
|
||||
toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
dismissible: false,
|
||||
action: (
|
||||
<a
|
||||
href={`${baseUrl}replay`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button>
|
||||
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
ns: "views/replay",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsStartingReplay(false);
|
||||
});
|
||||
}, [exportedRecording.id, navigate, t]);
|
||||
|
||||
// Resync the skeleton state whenever the backing export changes. The
|
||||
// list keys by id now, so in practice the component remounts instead
|
||||
@@ -301,6 +356,21 @@ export function ExportCard({
|
||||
{t("tooltip.downloadVideo")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("title", { ns: "views/replay" })}
|
||||
disabled={isStartingReplay}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDebugReplay();
|
||||
}}
|
||||
>
|
||||
{isStartingReplay
|
||||
? t("dialog.starting", { ns: "views/replay" })
|
||||
: t("title", { ns: "views/replay" })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && onAssignToCase && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
|
||||
@@ -19,7 +19,7 @@ const birdseye: SectionConfigOverrides = {
|
||||
],
|
||||
restartRequired: [],
|
||||
fieldOrder: ["enabled", "mode", "order"],
|
||||
hiddenFields: [],
|
||||
hiddenFields: ["order"],
|
||||
advancedFields: [],
|
||||
overrideFields: ["enabled", "mode"],
|
||||
uiSchema: {
|
||||
@@ -56,6 +56,7 @@ const birdseye: SectionConfigOverrides = {
|
||||
uiSchema: {
|
||||
mode: {
|
||||
"ui:size": "xs",
|
||||
"ui:after": { render: "BirdseyeCameraReorder" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -46,7 +46,11 @@ const record: SectionConfigOverrides = {
|
||||
uiSchema: {
|
||||
export: {
|
||||
hwaccel_args: {
|
||||
"ui:options": { suppressMultiSchema: true, size: "lg" },
|
||||
"ui:widget": "FfmpegArgsWidget",
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
ffmpegPresetField: "hwaccel_args",
|
||||
},
|
||||
},
|
||||
},
|
||||
"alerts.retain.mode": {
|
||||
|
||||
@@ -5,7 +5,7 @@ const ui: SectionConfigOverrides = {
|
||||
sectionDocs: "/configuration/reference",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["dashboard", "order"],
|
||||
hiddenFields: [],
|
||||
hiddenFields: ["order"],
|
||||
advancedFields: [],
|
||||
overrideFields: [],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { Reorder, useDragControls } from "framer-motion";
|
||||
import { LuCheck, LuGripVertical } from "react-icons/lu";
|
||||
import { SplitCardRow } from "@/components/card/SettingsGroupCard";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SectionRendererProps } from "./registry";
|
||||
|
||||
const SAVED_INDICATOR_MS = 1500;
|
||||
|
||||
type SaveStatus = "idle" | "saving" | "saved";
|
||||
|
||||
export default function BirdseyeCameraReorder({
|
||||
formContext,
|
||||
}: SectionRendererProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
const birdseyeCameras = useMemo(() => {
|
||||
if (!config) return [];
|
||||
return Object.keys(config.cameras)
|
||||
.filter(
|
||||
(name) =>
|
||||
config.cameras[name].enabled_in_config &&
|
||||
config.cameras[name].birdseye?.enabled !== false,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const orderA = config.cameras[a].birdseye?.order ?? 0;
|
||||
const orderB = config.cameras[b].birdseye?.order ?? 0;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
const [orderedCameras, setOrderedCameras] =
|
||||
useState<string[]>(birdseyeCameras);
|
||||
const orderedCamerasRef = useRef(orderedCameras);
|
||||
useEffect(() => {
|
||||
orderedCamerasRef.current = orderedCameras;
|
||||
}, [orderedCameras]);
|
||||
|
||||
useEffect(() => {
|
||||
setOrderedCameras((prev) => {
|
||||
if (
|
||||
prev.length === birdseyeCameras.length &&
|
||||
prev.every((cam, i) => cam === birdseyeCameras[i])
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return birdseyeCameras;
|
||||
});
|
||||
}, [birdseyeCameras]);
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||
const savedResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (savedResetTimerRef.current) {
|
||||
clearTimeout(savedResetTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(async () => {
|
||||
const current = orderedCamerasRef.current;
|
||||
if (
|
||||
current.length === birdseyeCameras.length &&
|
||||
current.every((cam, i) => cam === birdseyeCameras[i])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraUpdates: Record<string, { birdseye: { order: number } }> = {};
|
||||
current.forEach((cam, i) => {
|
||||
cameraUpdates[cam] = { birdseye: { order: i * 10 } };
|
||||
});
|
||||
|
||||
if (savedResetTimerRef.current) {
|
||||
clearTimeout(savedResetTimerRef.current);
|
||||
savedResetTimerRef.current = null;
|
||||
}
|
||||
setSaveStatus("saving");
|
||||
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: { cameras: cameraUpdates },
|
||||
});
|
||||
await updateConfig();
|
||||
setSaveStatus("saved");
|
||||
savedResetTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus("idle");
|
||||
savedResetTimerRef.current = null;
|
||||
}, SAVED_INDICATOR_MS);
|
||||
} catch (error) {
|
||||
setOrderedCameras(birdseyeCameras);
|
||||
setSaveStatus("idle");
|
||||
const errorMessage =
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.data?.message || error.response?.data?.detail)
|
||||
? error.response?.data?.message || error.response?.data?.detail
|
||||
: t("toast.save.error.noMessage", { ns: "common" });
|
||||
|
||||
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}, [birdseyeCameras, updateConfig, t]);
|
||||
|
||||
if (formContext?.level && formContext.level !== "global") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!config || birdseyeCameras.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SplitCardRow
|
||||
label={t("birdseye.cameraOrder.label", { ns: "views/settings" })}
|
||||
description={t("birdseye.cameraOrder.description", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
content={
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
axis="y"
|
||||
values={orderedCameras}
|
||||
onReorder={setOrderedCameras}
|
||||
className="space-y-2 rounded-lg bg-secondary p-4"
|
||||
>
|
||||
{orderedCameras.map((camera) => (
|
||||
<BirdseyeCameraRow
|
||||
key={camera}
|
||||
camera={camera}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
<SaveStatusIndicator status={saveStatus} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type SaveStatusIndicatorProps = {
|
||||
status: SaveStatus;
|
||||
};
|
||||
|
||||
function SaveStatusIndicator({ status }: SaveStatusIndicatorProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"flex h-4 items-center justify-start gap-1 text-xs transition-opacity duration-200",
|
||||
status === "idle" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{status === "saving" && (
|
||||
<span className="text-muted-foreground">
|
||||
{t("birdseye.cameraOrder.saving")}
|
||||
</span>
|
||||
)}
|
||||
{status === "saved" && (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<LuCheck className="size-3.5" />
|
||||
{t("birdseye.cameraOrder.saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BirdseyeCameraRowProps = {
|
||||
camera: string;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
|
||||
function BirdseyeCameraRow({ camera, onDragEnd }: BirdseyeCameraRowProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const controls = useDragControls();
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
as="div"
|
||||
value={camera}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
onDragEnd={onDragEnd}
|
||||
className="flex flex-row items-center gap-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
className="-ml-1 cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-primary active:cursor-grabbing"
|
||||
aria-label={t("birdseye.cameraOrder.reorderHandle")}
|
||||
>
|
||||
<LuGripVertical className="size-4" />
|
||||
</button>
|
||||
<CameraNameLabel camera={camera} />
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
|
||||
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
|
||||
import ProxyRoleMap from "./ProxyRoleMap";
|
||||
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
|
||||
import BirdseyeCameraReorder from "./BirdseyeCameraReorder";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
// Props that will be injected into all section renderers
|
||||
@@ -52,6 +53,9 @@ export const sectionRenderers: SectionRenderers = {
|
||||
notifications: {
|
||||
NotificationsSettingsExtras,
|
||||
},
|
||||
birdseye: {
|
||||
BirdseyeCameraReorder,
|
||||
},
|
||||
};
|
||||
|
||||
export default sectionRenderers;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Event } from "@/types/event";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { ReviewSegment, REVIEW_PADDING } from "@/types/review";
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
@@ -33,9 +36,14 @@ export default function DetailActionsMenu({
|
||||
setSearch,
|
||||
setSimilarity,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
|
||||
const { t } = useTranslation([
|
||||
"views/explore",
|
||||
"views/faceLibrary",
|
||||
"views/replay",
|
||||
]);
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const clipTimeRange = useMemo(() => {
|
||||
@@ -49,6 +57,54 @@ export default function DetailActionsMenu({
|
||||
search.data?.type === "audio" ? null : [`review/event/${search.id}`],
|
||||
);
|
||||
|
||||
const handleDebugReplay = useCallback(() => {
|
||||
setIsStarting(true);
|
||||
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: search.camera,
|
||||
start_time: search.start_time,
|
||||
end_time: search.end_time,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
navigate("/replay");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
|
||||
if (error.response?.status === 409) {
|
||||
toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
closeButton: true,
|
||||
dismissible: false,
|
||||
action: (
|
||||
<a
|
||||
href={`${baseUrl}replay`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button>
|
||||
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsStarting(false);
|
||||
});
|
||||
}, [navigate, search.camera, search.start_time, search.end_time, t]);
|
||||
|
||||
// don't render menu at all if no options are available
|
||||
const hasSemanticSearchOption =
|
||||
config?.semantic_search.enabled &&
|
||||
@@ -172,6 +228,24 @@ export default function DetailActionsMenu({
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{search.has_clip && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("itemMenu.debugReplay.aria")}
|
||||
disabled={isStarting}
|
||||
onSelect={() => {
|
||||
setIsOpen(false);
|
||||
handleDebugReplay();
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{isStarting
|
||||
? t("dialog.starting", { ns: "views/replay" })
|
||||
: t("itemMenu.debugReplay.label")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -59,6 +59,64 @@ function stripHiddenPaths(value: JsonValue, hiddenFields: string[]): JsonValue {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field paths that the backend resolves per-camera at runtime (from `fps`,
|
||||
* stream introspection, or other camera-local state) but defaults to `None`
|
||||
* in the global Pydantic model. Because the `/config` endpoint serializes
|
||||
* with `exclude_none=True`, these paths are absent from the global section
|
||||
* yet always populated on cameras, which would otherwise make every camera
|
||||
* appear to override fields the user never set globally.
|
||||
*/
|
||||
const AUTO_DERIVED_FIELDS: Record<string, readonly string[]> = {
|
||||
detect: [
|
||||
"width",
|
||||
"height",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"stationary.interval",
|
||||
"stationary.threshold",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop auto-derived field paths from the camera value when the global value
|
||||
* has no explicit setting for that path. If the user later sets one of these
|
||||
* fields globally, the path will be present in `globalValue` and normal
|
||||
* comparison resumes.
|
||||
*/
|
||||
function stripAutoDerivedMissingFromGlobal(
|
||||
sectionPath: string,
|
||||
globalValue: JsonValue,
|
||||
cameraValue: JsonValue,
|
||||
): JsonValue {
|
||||
const fields = AUTO_DERIVED_FIELDS[sectionPath];
|
||||
if (!fields || !isJsonObject(cameraValue)) return cameraValue;
|
||||
const cloned = cloneDeep(cameraValue) as JsonObject;
|
||||
for (const path of fields) {
|
||||
if (get(globalValue, path) === undefined) {
|
||||
unsetWithWildcard(cloned as Record<string, unknown>, path);
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given field is auto-derived for `sectionPath` and the global
|
||||
* value at that path is missing — in which case a per-camera value should
|
||||
* not be treated as an override.
|
||||
*/
|
||||
function isAutoDerivedMissingFromGlobal(
|
||||
sectionPath: string,
|
||||
fieldPath: string,
|
||||
globalValue: unknown,
|
||||
): boolean {
|
||||
const fields = AUTO_DERIVED_FIELDS[sectionPath];
|
||||
if (!fields) return false;
|
||||
if (!fields.includes(fieldPath)) return false;
|
||||
const value = get(globalValue as JsonObject, fieldPath);
|
||||
return value === undefined || value === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse null and empty-object values for override comparisons so
|
||||
* semantically equivalent shapes match. The schema may default `mask: None`
|
||||
@@ -234,10 +292,15 @@ export function useConfigOverride({
|
||||
collapseEmpty(normalizedGlobalValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripHiddenPaths(
|
||||
const collapsedCameraRaw = stripHiddenPaths(
|
||||
collapseEmpty(normalizedCameraValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
|
||||
sectionPath,
|
||||
collapsedGlobal,
|
||||
collapsedCameraRaw,
|
||||
);
|
||||
|
||||
const comparisonGlobal = compareFields
|
||||
? pickFields(collapsedGlobal, compareFields)
|
||||
@@ -258,6 +321,20 @@ export function useConfigOverride({
|
||||
const globalFieldValue = get(normalizedGlobalValue, fieldPath);
|
||||
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
|
||||
|
||||
if (
|
||||
isAutoDerivedMissingFromGlobal(
|
||||
sectionPath,
|
||||
fieldPath,
|
||||
normalizedGlobalValue,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
isOverridden: false,
|
||||
globalValue: globalFieldValue,
|
||||
cameraValue: cameraFieldValue,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isOverridden: !isEqual(
|
||||
collapseEmpty(globalFieldValue as JsonValue),
|
||||
@@ -367,10 +444,15 @@ export function useAllCameraOverrides(
|
||||
collapseEmpty(globalValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripHiddenPaths(
|
||||
const collapsedCameraRaw = stripHiddenPaths(
|
||||
collapseEmpty(cameraValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
|
||||
key,
|
||||
collapsedGlobal,
|
||||
collapsedCameraRaw,
|
||||
);
|
||||
const comparisonGlobal = compareFields
|
||||
? pickFields(collapsedGlobal, compareFields)
|
||||
: collapsedGlobal;
|
||||
@@ -615,7 +697,11 @@ export function useCamerasOverridingSection(
|
||||
const deltasByPath = new Map<string, FieldDelta>();
|
||||
|
||||
// 1. Camera-level overrides (uses base_config when a profile is active)
|
||||
const cameraValue = collapseEmpty(cameraSectionValues[idx]);
|
||||
const cameraValue = stripAutoDerivedMissingFromGlobal(
|
||||
sectionPath,
|
||||
globalValue,
|
||||
collapseEmpty(cameraSectionValues[idx]),
|
||||
);
|
||||
for (const delta of collectFieldDeltas(
|
||||
globalValue,
|
||||
cameraValue,
|
||||
@@ -696,9 +782,13 @@ export function useCameraSectionDeltas(
|
||||
const globalValue = collapseEmpty(
|
||||
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
|
||||
);
|
||||
const cameraValue = collapseEmpty(
|
||||
normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, cameraName, sectionPath),
|
||||
const cameraValue = stripAutoDerivedMissingFromGlobal(
|
||||
sectionPath,
|
||||
globalValue,
|
||||
collapseEmpty(
|
||||
normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, cameraName, sectionPath),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
+18
-14
@@ -10,7 +10,7 @@ import useSWR from "swr";
|
||||
import useDeepMemo from "./use-deep-memo";
|
||||
import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { useFrigateStats } from "@/api/ws";
|
||||
import { useFrigateStats, useJobStatus } from "@/api/ws";
|
||||
import { useIsAdmin } from "./use-is-admin";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -19,11 +19,15 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const isAdmin = useIsAdmin();
|
||||
const { data: debugReplayStatus } = useSWR(
|
||||
isAdmin ? "debug_replay/status" : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
|
||||
// Pass isAdmin as revalidateOnFocus so non-admins never send the jobState snapshot pull
|
||||
const { payload: replayJob } = useJobStatus("debug_replay", isAdmin);
|
||||
const replayActive = Boolean(
|
||||
isAdmin &&
|
||||
replayJob &&
|
||||
(replayJob.status === "queued" ||
|
||||
replayJob.status === "running" ||
|
||||
replayJob.status === "success"),
|
||||
);
|
||||
|
||||
const memoizedStats = useDeepMemo(stats);
|
||||
@@ -102,6 +106,11 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
|
||||
// check camera cpu usages
|
||||
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
|
||||
// Skip replay cameras
|
||||
if (isReplayCamera(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ffmpegAvg = parseFloat(
|
||||
memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
|
||||
);
|
||||
@@ -111,12 +120,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
|
||||
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
||||
|
||||
// Skip ffmpeg warnings for replay cameras
|
||||
if (
|
||||
!isNaN(ffmpegAvg) &&
|
||||
ffmpegAvg >= CameraFfmpegThreshold.error &&
|
||||
!isReplayCamera(name)
|
||||
) {
|
||||
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
||||
problems.push({
|
||||
text: t("stats.ffmpegHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
@@ -140,7 +144,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
});
|
||||
|
||||
// Add message if debug replay is active
|
||||
if (debugReplayStatus?.active) {
|
||||
if (replayActive) {
|
||||
problems.push({
|
||||
text: t("stats.debugReplayActive", {
|
||||
defaultValue: "Debug replay session is active",
|
||||
@@ -151,7 +155,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
}
|
||||
|
||||
return problems;
|
||||
}, [config, memoizedStats, t, debugReplayStatus]);
|
||||
}, [config, memoizedStats, t, replayActive]);
|
||||
|
||||
return { potentialProblems };
|
||||
}
|
||||
|
||||
@@ -106,6 +106,12 @@ import SaveAllPreviewPopover, {
|
||||
type SaveAllPreviewItem,
|
||||
} from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||
import { useRestart } from "@/api/ws";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
|
||||
const allSettingsViews = [
|
||||
"uiSettings",
|
||||
@@ -1505,10 +1511,20 @@ export default function Settings() {
|
||||
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
|
||||
const showUnsavedDot = status?.hasChanges;
|
||||
|
||||
const dotColor =
|
||||
status?.overrideSource === "profile" && activeProfileColor
|
||||
? activeProfileColor.dot
|
||||
: "bg-selected";
|
||||
const isProfileOverride =
|
||||
status?.overrideSource === "profile" && activeProfileColor;
|
||||
const dotColor = isProfileOverride
|
||||
? activeProfileColor.dot
|
||||
: "bg-selected";
|
||||
|
||||
const overrideTooltip = isProfileOverride
|
||||
? t("menuDot.overrideProfile", {
|
||||
profile: activeEditingProfile
|
||||
? (profileFriendlyNames.get(activeEditingProfile) ??
|
||||
activeEditingProfile)
|
||||
: "",
|
||||
})
|
||||
: t("menuDot.overrideGlobal");
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 items-center justify-between pr-4 md:pr-0">
|
||||
@@ -1518,19 +1534,46 @@ export default function Settings() {
|
||||
{(showOverrideDot || showUnsavedDot) && (
|
||||
<div className="ml-2 flex shrink-0 items-center gap-2">
|
||||
{showOverrideDot && (
|
||||
<span
|
||||
className={cn("inline-block size-2 rounded-full", dotColor)}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
dotColor,
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right">
|
||||
{overrideTooltip}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showUnsavedDot && (
|
||||
<span className="inline-block size-2 rounded-full bg-unsaved" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block size-2 rounded-full bg-unsaved" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right">
|
||||
{t("menuDot.unsaved")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[sectionStatusByKey, t, activeProfileColor],
|
||||
[
|
||||
sectionStatusByKey,
|
||||
t,
|
||||
activeProfileColor,
|
||||
activeEditingProfile,
|
||||
profileFriendlyNames,
|
||||
],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CONTROL_COLUMN_CLASS_NAME,
|
||||
SettingsGroupCard,
|
||||
@@ -14,7 +14,15 @@ import { useTranslation } from "react-i18next";
|
||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
||||
import { LuExternalLink, LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
LuCheck,
|
||||
LuExternalLink,
|
||||
LuGripVertical,
|
||||
LuPencil,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import { Reorder, useDragControls } from "framer-motion";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
@@ -45,6 +53,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const REORDER_SAVED_INDICATOR_MS = 1500;
|
||||
|
||||
type ReorderSaveStatus = "idle" | "saving" | "saved";
|
||||
|
||||
type CameraManagementViewProps = {
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
profileState?: ProfileState;
|
||||
@@ -54,7 +66,7 @@ export default function CameraManagementView({
|
||||
setUnsavedChanges,
|
||||
profileState,
|
||||
}: CameraManagementViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
@@ -72,16 +84,99 @@ export default function CameraManagementView({
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const { send: sendRestart } = useRestart();
|
||||
|
||||
// List of cameras for dropdown
|
||||
const enabledCameras = useMemo(() => {
|
||||
if (config) {
|
||||
return Object.keys(config.cameras)
|
||||
.filter((camera) => config.cameras[camera].enabled_in_config)
|
||||
.sort();
|
||||
.sort((a, b) => {
|
||||
const orderA = config.cameras[a].ui?.order ?? 0;
|
||||
const orderB = config.cameras[b].ui?.order ?? 0;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}, [config]);
|
||||
|
||||
// Diverges from config during a drag and while the save is in flight.
|
||||
const [orderedCameras, setOrderedCameras] =
|
||||
useState<string[]>(enabledCameras);
|
||||
const orderedCamerasRef = useRef(orderedCameras);
|
||||
useEffect(() => {
|
||||
orderedCamerasRef.current = orderedCameras;
|
||||
}, [orderedCameras]);
|
||||
|
||||
useEffect(() => {
|
||||
setOrderedCameras((prev) => {
|
||||
if (
|
||||
prev.length === enabledCameras.length &&
|
||||
prev.every((cam, i) => cam === enabledCameras[i])
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return enabledCameras;
|
||||
});
|
||||
}, [enabledCameras]);
|
||||
|
||||
const [reorderSaveStatus, setReorderSaveStatus] =
|
||||
useState<ReorderSaveStatus>("idle");
|
||||
const reorderSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reorderSavedTimerRef.current) {
|
||||
clearTimeout(reorderSavedTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleReorderDragEnd = useCallback(async () => {
|
||||
const current = orderedCamerasRef.current;
|
||||
if (
|
||||
current.length === enabledCameras.length &&
|
||||
current.every((cam, i) => cam === enabledCameras[i])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraUpdates: Record<string, { ui: { order: number } }> = {};
|
||||
current.forEach((cam, i) => {
|
||||
cameraUpdates[cam] = { ui: { order: i * 10 } };
|
||||
});
|
||||
|
||||
if (reorderSavedTimerRef.current) {
|
||||
clearTimeout(reorderSavedTimerRef.current);
|
||||
reorderSavedTimerRef.current = null;
|
||||
}
|
||||
setReorderSaveStatus("saving");
|
||||
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: { cameras: cameraUpdates },
|
||||
});
|
||||
await updateConfig();
|
||||
setReorderSaveStatus("saved");
|
||||
reorderSavedTimerRef.current = setTimeout(() => {
|
||||
setReorderSaveStatus("idle");
|
||||
reorderSavedTimerRef.current = null;
|
||||
}, REORDER_SAVED_INDICATOR_MS);
|
||||
} catch (error) {
|
||||
setOrderedCameras(enabledCameras);
|
||||
setReorderSaveStatus("idle");
|
||||
const errorMessage =
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.data?.message || error.response?.data?.detail)
|
||||
? error.response?.data?.message || error.response?.data?.detail
|
||||
: t("toast.save.error.noMessage", { ns: "common" });
|
||||
|
||||
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}, [enabledCameras, updateConfig, t]);
|
||||
|
||||
const disabledCameras = useMemo(() => {
|
||||
if (config) {
|
||||
return Object.keys(config.cameras)
|
||||
@@ -173,22 +268,26 @@ export default function CameraManagementView({
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||
{enabledCameras.map((camera) => (
|
||||
<div
|
||||
key={camera}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<CameraNameLabel camera={camera} />
|
||||
<CameraFriendlyNameEditor
|
||||
cameraName={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
/>
|
||||
</div>
|
||||
<CameraEnableSwitch cameraName={camera} />
|
||||
</div>
|
||||
))}
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
axis="y"
|
||||
values={orderedCameras}
|
||||
onReorder={setOrderedCameras}
|
||||
className="space-y-2 rounded-lg bg-secondary p-4"
|
||||
>
|
||||
{orderedCameras.map((camera) => (
|
||||
<EnabledCameraRow
|
||||
key={camera}
|
||||
camera={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
onDragEnd={handleReorderDragEnd}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
<ReorderSaveStatusIndicator
|
||||
status={reorderSaveStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground md:hidden">
|
||||
<Trans ns="views/settings">
|
||||
@@ -309,6 +408,80 @@ export default function CameraManagementView({
|
||||
);
|
||||
}
|
||||
|
||||
type ReorderSaveStatusIndicatorProps = {
|
||||
status: ReorderSaveStatus;
|
||||
};
|
||||
|
||||
function ReorderSaveStatusIndicator({
|
||||
status,
|
||||
}: ReorderSaveStatusIndicatorProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"flex h-4 items-center justify-start gap-1 text-xs transition-opacity duration-200",
|
||||
status === "idle" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{status === "saving" && (
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraManagement.streams.saving")}
|
||||
</span>
|
||||
)}
|
||||
{status === "saved" && (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<LuCheck className="size-3.5" />
|
||||
{t("cameraManagement.streams.saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type EnabledCameraRowProps = {
|
||||
camera: string;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
|
||||
function EnabledCameraRow({
|
||||
camera,
|
||||
onConfigChanged,
|
||||
onDragEnd,
|
||||
}: EnabledCameraRowProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const controls = useDragControls();
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
as="div"
|
||||
value={camera}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
onDragEnd={onDragEnd}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
className="-ml-1 cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-primary active:cursor-grabbing"
|
||||
aria-label={t("cameraManagement.streams.reorderHandle")}
|
||||
>
|
||||
<LuGripVertical className="size-4" />
|
||||
</button>
|
||||
<CameraNameLabel camera={camera} />
|
||||
<CameraFriendlyNameEditor
|
||||
cameraName={camera}
|
||||
onConfigChanged={onConfigChanged}
|
||||
/>
|
||||
</div>
|
||||
<CameraEnableSwitch cameraName={camera} />
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraEnableSwitchProps = {
|
||||
cameraName: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user