mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-24 07:24:42 +03:00
Merge branch 'blakeblackshear:dev' into dev
This commit is contained in:
@@ -22,7 +22,7 @@ export default function ActionsDropdown({
|
||||
const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
|
||||
@@ -102,6 +102,19 @@ export default function ClassificationSelectionDialog({
|
||||
// control
|
||||
const [newClass, setNewClass] = useState(false);
|
||||
|
||||
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
|
||||
// scroll containers, so attach a non-passive listener that scrolls manually.
|
||||
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (!el || !isDesktop) return;
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (el.scrollHeight <= el.clientHeight) return;
|
||||
e.preventDefault();
|
||||
el.scrollTop += e.deltaY;
|
||||
};
|
||||
el.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handleWheel);
|
||||
}, []);
|
||||
|
||||
// components
|
||||
const Selector = isDesktop ? DropdownMenu : Drawer;
|
||||
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||
@@ -114,6 +127,8 @@ export default function ClassificationSelectionDialog({
|
||||
</DrawerClose>
|
||||
);
|
||||
|
||||
// keep modal false on desktop to prevent dismissable layer pointer events
|
||||
// issue with dialog auto-close
|
||||
return (
|
||||
<div className={className ?? "flex"}>
|
||||
<TextEntryDialog
|
||||
@@ -122,60 +137,60 @@ export default function ClassificationSelectionDialog({
|
||||
title={t("createCategory.new")}
|
||||
onSave={(newCat) => onCategorizeImage(newCat)}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<Selector>
|
||||
<SelectorTrigger asChild>
|
||||
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
|
||||
</SelectorTrigger>
|
||||
<SelectorContent
|
||||
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("categorizeImageAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[40dvh] flex-col overflow-y-auto",
|
||||
isMobile && "gap-2 pb-4",
|
||||
)}
|
||||
<Selector {...(isDesktop ? { modal: false } : {})}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={isChildButton}>
|
||||
<SelectorTrigger asChild>{children}</SelectorTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tooltipLabel ?? t("categorizeImage")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectorContent
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
|
||||
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
|
||||
)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("categorizeImageAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div className={cn("flex flex-col", isMobile && "gap-2 pb-4")}>
|
||||
{filteredClasses
|
||||
.sort((a, b) => {
|
||||
if (a === "none") return 1;
|
||||
if (b === "none") return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((category) => (
|
||||
<SelectorItem
|
||||
key={category}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onCategorizeImage(category)}
|
||||
>
|
||||
{category === "none"
|
||||
? t("details.none")
|
||||
: category.replaceAll("_", " ")}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<Separator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewClass(true)}
|
||||
>
|
||||
{filteredClasses
|
||||
.sort((a, b) => {
|
||||
if (a === "none") return 1;
|
||||
if (b === "none") return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((category) => (
|
||||
<SelectorItem
|
||||
key={category}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onCategorizeImage(category)}
|
||||
>
|
||||
{category === "none"
|
||||
? t("details.none")
|
||||
: category.replaceAll("_", " ")}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<Separator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewClass(true)}
|
||||
>
|
||||
{t("createCategory.new")}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
<TooltipContent>{tooltipLabel ?? t("categorizeImage")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{t("createCategory.new")}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export function CustomTimeSelector({
|
||||
{formattedStart}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-center" disablePortal>
|
||||
<PopoverContent className="flex flex-col items-center">
|
||||
<TimezoneAwareCalendar
|
||||
timezone={config?.ui.timezone}
|
||||
selectedDay={new Date(startTime * 1000)}
|
||||
@@ -193,7 +193,7 @@ export function CustomTimeSelector({
|
||||
{formattedEnd}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-center" disablePortal>
|
||||
<PopoverContent className="flex flex-col items-center">
|
||||
<TimezoneAwareCalendar
|
||||
timezone={config?.ui.timezone}
|
||||
selectedDay={new Date(endTime * 1000)}
|
||||
|
||||
@@ -209,10 +209,7 @@ export default function DebugReplayDialog({
|
||||
end_time: range.before,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
navigate("/replay");
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React, { ReactNode, useMemo, useState } from "react";
|
||||
import React, { ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import TextEntryDialog from "./dialog/TextEntryDialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
@@ -61,6 +61,19 @@ export default function FaceSelectionDialog({
|
||||
// control
|
||||
const [newFace, setNewFace] = useState(false);
|
||||
|
||||
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
|
||||
// scroll containers, so attach a non-passive listener that scrolls manually.
|
||||
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (!el || !isDesktop) return;
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (el.scrollHeight <= el.clientHeight) return;
|
||||
e.preventDefault();
|
||||
el.scrollTop += e.deltaY;
|
||||
};
|
||||
el.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handleWheel);
|
||||
}, []);
|
||||
|
||||
// components
|
||||
const Selector = isDesktop ? DropdownMenu : Drawer;
|
||||
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||
@@ -73,6 +86,8 @@ export default function FaceSelectionDialog({
|
||||
</DrawerClose>
|
||||
);
|
||||
|
||||
// keep modal false on desktop to prevent dismissable layer pointer events
|
||||
// issue with dialog auto-close
|
||||
return (
|
||||
<div className={className ?? "flex"}>
|
||||
{newFace && (
|
||||
@@ -83,52 +98,56 @@ export default function FaceSelectionDialog({
|
||||
onSave={(newName) => onTrainAttempt(newName)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<Selector>
|
||||
<SelectorTrigger asChild>
|
||||
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
|
||||
</SelectorTrigger>
|
||||
<SelectorContent
|
||||
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<Selector {...(isDesktop ? { modal: false } : {})}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={isChildButton}>
|
||||
<SelectorTrigger asChild>{children}</SelectorTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectorContent
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
|
||||
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
|
||||
)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("trainFaceAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
isMobile &&
|
||||
"max-h-[40dvh] gap-2 overflow-y-auto overflow-x-hidden pb-4",
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("trainFaceAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
|
||||
isMobile && "gap-2 pb-4",
|
||||
)}
|
||||
>
|
||||
{filteredNames.sort().map((faceName) => (
|
||||
<SelectorItem
|
||||
key={faceName}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onTrainAttempt(faceName)}
|
||||
>
|
||||
{faceName}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
>
|
||||
{filteredNames.sort().map((faceName) => (
|
||||
<SelectorItem
|
||||
key={faceName}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewFace(true)}
|
||||
onClick={() => onTrainAttempt(faceName)}
|
||||
>
|
||||
{t("createFaceLibrary.new")}
|
||||
{faceName}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewFace(true)}
|
||||
>
|
||||
{t("createFaceLibrary.new")}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,10 +262,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
end_time: debugReplayRange.before,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
setDebugReplayMode("none");
|
||||
setDebugReplayRange(undefined);
|
||||
setDrawerMode("none");
|
||||
@@ -391,10 +388,8 @@ export default function MobileReviewSettingsDrawer({
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("title", { ns: "views/replay" })}
|
||||
onClick={() => {
|
||||
const now = new Date(latestTime * 1000);
|
||||
now.setHours(now.getHours() - 1);
|
||||
setDebugReplayRange({
|
||||
after: now.getTime() / 1000,
|
||||
after: latestTime - 60,
|
||||
before: latestTime,
|
||||
});
|
||||
setSelectedReplayOption("1");
|
||||
@@ -541,11 +536,9 @@ export default function MobileReviewSettingsDrawer({
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = parseInt(option);
|
||||
const minutes = parseInt(option, 10);
|
||||
const end = latestTime;
|
||||
const now = new Date(end * 1000);
|
||||
now.setHours(now.getHours() - hours);
|
||||
setDebugReplayRange({ after: now.getTime() / 1000, before: end });
|
||||
setDebugReplayRange({ after: end - minutes * 60, before: end });
|
||||
};
|
||||
|
||||
content = (
|
||||
|
||||
@@ -178,6 +178,7 @@ export default function MultiExportDialog({
|
||||
start_time: review.start_time - REVIEW_PADDING,
|
||||
end_time: (review.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
image_path: review.thumb_path || undefined,
|
||||
friendly_name: review.data.metadata?.title?.trim() || undefined,
|
||||
client_item_id: review.id,
|
||||
}));
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { CameraPtzInfo } from "@/types/ptz";
|
||||
import React, { useCallback } from "react";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { BsThreeDotsVertical } from "react-icons/bs";
|
||||
import {
|
||||
FaAngleDown,
|
||||
@@ -292,7 +292,7 @@ export default function PtzControlPanel({
|
||||
</Tooltip>
|
||||
)}
|
||||
{(ptz?.presets?.length ?? 0) > 0 && (
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { throttle } from "lodash";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
|
||||
@@ -19,11 +21,21 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const SLIDER_DRAG_THROTTLE_MS = 80;
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
// Optional side-effect invoked atomically with setAnnotationOffset (inside
|
||||
// flushSync) so callers like the timeline panel can re-seek the video in the
|
||||
// same React commit as the offset state update — preventing a one-frame
|
||||
// overlay mismatch where annotationOffset has changed but currentTime has not.
|
||||
onApplyOffset?: (newOffset: number) => void;
|
||||
};
|
||||
|
||||
export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
export default function AnnotationOffsetSlider({
|
||||
className,
|
||||
onApplyOffset,
|
||||
}: Props) {
|
||||
const { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
|
||||
const isAdmin = useIsAdmin();
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
@@ -31,31 +43,62 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const applyOffset = useCallback(
|
||||
(newOffset: number) => {
|
||||
flushSync(() => {
|
||||
setAnnotationOffset(newOffset);
|
||||
onApplyOffset?.(newOffset);
|
||||
});
|
||||
},
|
||||
[setAnnotationOffset, onApplyOffset],
|
||||
);
|
||||
|
||||
const throttledApplyOffset = useMemo(
|
||||
() =>
|
||||
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
|
||||
leading: true,
|
||||
trailing: true,
|
||||
}),
|
||||
[applyOffset],
|
||||
);
|
||||
|
||||
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
const valueMs = values[0];
|
||||
setAnnotationOffset(valueMs);
|
||||
throttledApplyOffset(values[0]);
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
[throttledApplyOffset],
|
||||
);
|
||||
|
||||
const handleCommit = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
// Ensure the final value lands even if it would otherwise be discarded
|
||||
// by the trailing edge of the throttle window.
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(values[0]);
|
||||
},
|
||||
[throttledApplyOffset, applyOffset],
|
||||
);
|
||||
|
||||
const stepOffset = useCallback(
|
||||
(delta: number) => {
|
||||
setAnnotationOffset((prev) => {
|
||||
const next = prev + delta;
|
||||
return Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
||||
);
|
||||
});
|
||||
const next = Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
|
||||
);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(next);
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
[annotationOffset, applyOffset, throttledApplyOffset],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setAnnotationOffset(0);
|
||||
}, [setAnnotationOffset]);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(0);
|
||||
}, [applyOffset, throttledApplyOffset]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -130,6 +173,7 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
max={ANNOTATION_OFFSET_MAX}
|
||||
step={ANNOTATION_OFFSET_STEP}
|
||||
onValueChange={handleChange}
|
||||
onValueCommit={handleCommit}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Event } from "@/types/event";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import axios from "axios";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { throttle } from "lodash";
|
||||
import { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@@ -19,6 +21,8 @@ import {
|
||||
ANNOTATION_OFFSET_STEP,
|
||||
} from "@/lib/const";
|
||||
|
||||
const SLIDER_DRAG_THROTTLE_MS = 80;
|
||||
|
||||
type AnnotationSettingsPaneProps = {
|
||||
event: Event;
|
||||
annotationOffset: number;
|
||||
@@ -38,30 +42,64 @@ export function AnnotationSettingsPane({
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
setAnnotationOffset(values[0]);
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
);
|
||||
|
||||
const stepOffset = useCallback(
|
||||
(delta: number) => {
|
||||
setAnnotationOffset((prev) => {
|
||||
const next = prev + delta;
|
||||
return Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
||||
);
|
||||
// flushSync ensures setAnnotationOffset commits synchronously so the
|
||||
// useLayoutEffect in TrackingDetails (which seeks the video and sets
|
||||
// currentTime in response) runs before the browser paints — preventing a
|
||||
// one-frame overlay mismatch where annotationOffset has changed but
|
||||
// currentTime has not.
|
||||
const applyOffset = useCallback(
|
||||
(newOffset: number) => {
|
||||
flushSync(() => {
|
||||
setAnnotationOffset(newOffset);
|
||||
});
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
);
|
||||
|
||||
const throttledApplyOffset = useMemo(
|
||||
() =>
|
||||
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
|
||||
leading: true,
|
||||
trailing: true,
|
||||
}),
|
||||
[applyOffset],
|
||||
);
|
||||
|
||||
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
throttledApplyOffset(values[0]);
|
||||
},
|
||||
[throttledApplyOffset],
|
||||
);
|
||||
|
||||
const handleSliderCommit = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(values[0]);
|
||||
},
|
||||
[throttledApplyOffset, applyOffset],
|
||||
);
|
||||
|
||||
const stepOffset = useCallback(
|
||||
(delta: number) => {
|
||||
const next = Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
|
||||
);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(next);
|
||||
},
|
||||
[annotationOffset, applyOffset, throttledApplyOffset],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setAnnotationOffset(0);
|
||||
}, [setAnnotationOffset]);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(0);
|
||||
}, [applyOffset, throttledApplyOffset]);
|
||||
|
||||
const saveToConfig = useCallback(async () => {
|
||||
if (!config || !event) return;
|
||||
@@ -143,6 +181,7 @@ export function AnnotationSettingsPane({
|
||||
max={ANNOTATION_OFFSET_MAX}
|
||||
step={ANNOTATION_OFFSET_STEP}
|
||||
onValueChange={handleSliderChange}
|
||||
onValueCommit={handleSliderCommit}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function DetailActionsMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
|
||||
@@ -241,7 +241,7 @@ function AnnotationSettings({
|
||||
|
||||
return (
|
||||
<div className="ml-2">
|
||||
<Overlay modal={isDesktop} open={open} onOpenChange={handleOpenChange}>
|
||||
<Overlay open={open} onOpenChange={handleOpenChange}>
|
||||
<Trigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -269,7 +269,6 @@ function AnnotationSettings({
|
||||
: "mx-1 max-h-[75dvh] overflow-hidden rounded-t-2xl px-4 pb-4"
|
||||
}
|
||||
{...contentProps}
|
||||
{...(isDesktop ? { disablePortal: true } : {})}
|
||||
data-annotation-popover
|
||||
>
|
||||
<AnnotationSettingsPane
|
||||
@@ -958,8 +957,9 @@ function ObjectDetailsTab({
|
||||
toast.success(
|
||||
t("details.item.toast.success.regenerate", {
|
||||
provider: capitalizeAll(
|
||||
config?.genai.provider.replaceAll("_", " ") ??
|
||||
t("generativeAI"),
|
||||
Object.values(config?.genai ?? {})
|
||||
.find((agent) => agent?.roles?.includes("descriptions"))
|
||||
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
|
||||
),
|
||||
}),
|
||||
{
|
||||
@@ -977,8 +977,9 @@ function ObjectDetailsTab({
|
||||
toast.error(
|
||||
t("details.item.toast.error.regenerate", {
|
||||
provider: capitalizeAll(
|
||||
config?.genai.provider.replaceAll("_", " ") ??
|
||||
t("generativeAI"),
|
||||
Object.values(config?.genai ?? {})
|
||||
.find((agent) => agent?.roles?.includes("descriptions"))
|
||||
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
|
||||
),
|
||||
errorMessage,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import useSWR from "swr";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import { Event } from "@/types/event";
|
||||
@@ -389,7 +397,12 @@ export function TrackingDetails({
|
||||
|
||||
// When the pinned timestamp or offset changes, re-seek the video and
|
||||
// explicitly update currentTime so the overlay shows the pinned event's box.
|
||||
useEffect(() => {
|
||||
// useLayoutEffect + flushSync force the setCurrentTime commit to land before
|
||||
// the browser paints, so the overlay never shows a frame where
|
||||
// annotationOffset has changed but currentTime has not — that mismatch would
|
||||
// resolve effectiveCurrentTime away from the pinned detect timestamp and
|
||||
// make the bounding box disappear or jump for one frame.
|
||||
useLayoutEffect(() => {
|
||||
const pinned = pinnedDetectTimestampRef.current;
|
||||
if (!isAnnotationSettingsOpen || pinned == null) return;
|
||||
if (!videoRef.current || displaySource !== "video") return;
|
||||
@@ -398,10 +411,9 @@ export function TrackingDetails({
|
||||
const relativeTime = timestampToVideoTime(targetTimeRecord);
|
||||
videoRef.current.currentTime = relativeTime;
|
||||
|
||||
// Explicitly update currentTime state so the overlay's effectiveCurrentTime
|
||||
// resolves back to the pinned detect timestamp:
|
||||
// effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned
|
||||
setCurrentTime(targetTimeRecord);
|
||||
flushSync(() => {
|
||||
setCurrentTime(targetTimeRecord);
|
||||
});
|
||||
}, [
|
||||
isAnnotationSettingsOpen,
|
||||
annotationOffset,
|
||||
@@ -1204,7 +1216,11 @@ function LifecycleIconRow({
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
|
||||
{isAdmin && (config?.plus?.enabled || item.data.box) && (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded p-1 pr-2" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
|
||||
@@ -38,12 +38,6 @@ export default function RestartDialog({
|
||||
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
|
||||
const [countdown, setCountdown] = useState(60);
|
||||
|
||||
const clearBodyPointerEvents = () => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.body.style.pointerEvents = "";
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setRestartDialogOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
@@ -85,16 +79,10 @@ export default function RestartDialog({
|
||||
if (!open) {
|
||||
setRestartDialogOpen(false);
|
||||
onClose();
|
||||
clearBodyPointerEvents();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
clearBodyPointerEvents();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("restart.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="sr-only">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import TextEntry from "@/components/input/TextEntry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -19,7 +20,9 @@ type TextEntryDialogProps = {
|
||||
setOpen: (open: boolean) => void;
|
||||
onSave: (text: string) => void;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
allowEmpty?: boolean;
|
||||
isSaving?: boolean;
|
||||
regexPattern?: RegExp;
|
||||
regexErrorMessage?: string;
|
||||
forbiddenPattern?: RegExp;
|
||||
@@ -33,7 +36,9 @@ export default function TextEntryDialog({
|
||||
setOpen,
|
||||
onSave,
|
||||
defaultValue = "",
|
||||
placeholder,
|
||||
allowEmpty = false,
|
||||
isSaving = false,
|
||||
regexPattern,
|
||||
regexErrorMessage,
|
||||
forbiddenPattern,
|
||||
@@ -50,6 +55,7 @@ export default function TextEntryDialog({
|
||||
</DialogHeader>
|
||||
<TextEntry
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
allowEmpty={allowEmpty}
|
||||
onSave={onSave}
|
||||
regexPattern={regexPattern}
|
||||
@@ -58,11 +64,22 @@ export default function TextEntryDialog({
|
||||
forbiddenErrorMessage={forbiddenErrorMessage}
|
||||
>
|
||||
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("button.cancel")}
|
||||
</Button>
|
||||
<Button variant="select" type="submit">
|
||||
{t("button.save")}
|
||||
<Button variant="select" type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</TextEntry>
|
||||
|
||||
Reference in New Issue
Block a user