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"; import { useDetailStream } from "@/context/detail-stream-context"; import axios from "axios"; import { useSWRConfig } from "swr"; import { toast } from "sonner"; import { Trans, useTranslation } from "react-i18next"; import { LuExternalLink, LuInfo, LuMinus, LuPlus } from "react-icons/lu"; import { cn } from "@/lib/utils"; import { ANNOTATION_OFFSET_MAX, ANNOTATION_OFFSET_MIN, ANNOTATION_OFFSET_STEP, } from "@/lib/const"; import { isMobile } from "react-device-detect"; 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, onApplyOffset, }: Props) { const { annotationOffset, setAnnotationOffset, camera } = useDetailStream(); const isAdmin = useIsAdmin(); const { getLocaleDocUrl } = useDocDomain(); const { mutate } = useSWRConfig(); 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; throttledApplyOffset(values[0]); }, [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) => { const next = Math.max( ANNOTATION_OFFSET_MIN, Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta), ); throttledApplyOffset.cancel(); applyOffset(next); }, [annotationOffset, applyOffset, throttledApplyOffset], ); const reset = useCallback(() => { throttledApplyOffset.cancel(); applyOffset(0); }, [applyOffset, throttledApplyOffset]); const save = useCallback(async () => { setIsSaving(true); try { // save value in milliseconds to config await axios.put( `config/set?cameras.${camera}.detect.annotation_offset=${annotationOffset}`, { requires_restart: 0 }, ); toast.success( t("trackingDetails.annotationSettings.offset.toast.success", { camera, }), { position: "top-center" }, ); // refresh config await mutate("config"); } catch (e: unknown) { const err = e as { response?: { data?: { message?: string } }; message?: string; }; const errorMessage = err?.response?.data?.message || err?.message || "Unknown error"; toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), { position: "top-center", }); } finally { setIsSaving(false); } }, [annotationOffset, camera, mutate, t]); return (