diff --git a/web/src/components/timeline/VirtualizedEventSegments.tsx b/web/src/components/timeline/VirtualizedEventSegments.tsx index 23ad49ffcc..4040c6e6be 100644 --- a/web/src/components/timeline/VirtualizedEventSegments.tsx +++ b/web/src/components/timeline/VirtualizedEventSegments.tsx @@ -73,7 +73,9 @@ export const VirtualizedEventSegments = forwardRef< Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) + OVERSCAN_COUNT, ); - setVisibleRange({ start, end }); + setVisibleRange((prev) => + prev.start === start && prev.end === end ? prev : { start, end }, + ); } }, [segments.length, timelineRef]); diff --git a/web/src/components/timeline/VirtualizedMotionSegments.tsx b/web/src/components/timeline/VirtualizedMotionSegments.tsx index 240192911d..a98593d893 100644 --- a/web/src/components/timeline/VirtualizedMotionSegments.tsx +++ b/web/src/components/timeline/VirtualizedMotionSegments.tsx @@ -77,7 +77,9 @@ export const VirtualizedMotionSegments = forwardRef< Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) + OVERSCAN_COUNT, ); - setVisibleRange({ start, end }); + setVisibleRange((prev) => + prev.start === start && prev.end === end ? prev : { start, end }, + ); } }, [segments.length, timelineRef]); diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index bf43901e88..a24981a2ce 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTimelineUtils } from "./use-timeline-utils"; import { FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; @@ -8,6 +8,8 @@ import { useTimeFormat } from "./use-date-utils"; import { useTranslation } from "react-i18next"; import useUserInteraction from "./use-user-interaction"; +const DRAG_STATE_COMMIT_MS = 100; + type DraggableElementProps = { contentRef: React.RefObject; timelineRef: React.RefObject; @@ -61,6 +63,8 @@ function useDraggableElement({ const [clientYPosition, setClientYPosition] = useState(null); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); + const lastDragTimeCommitRef = useRef(0); + const pendingDragTimeRef = useRef(null); const [elementScrollIntoView, setElementScrollIntoView] = useState(true); const [scrollEdgeSize, setScrollEdgeSize] = useState(); const [fullTimelineHeight, setFullTimelineHeight] = useState(); @@ -154,9 +158,14 @@ function useDraggableElement({ if (isDragging) { setIsDragging(false); setInitialClickAdjustment(0); + + if (pendingDragTimeRef.current !== null && setDraggableElementTime) { + setDraggableElementTime(pendingDragTimeRef.current); + pendingDragTimeRef.current = null; + } } }, - [isDragging, setIsDragging], + [isDragging, setIsDragging, setDraggableElementTime], ); const timestampToPixels = useCallback( @@ -346,9 +355,21 @@ function useDraggableElement({ ); if (setDraggableElementTime) { - setDraggableElementTime( - targetSegmentTime + segmentDuration * (offset / segmentHeight), - ); + const newTime = + targetSegmentTime + segmentDuration * (offset / segmentHeight); + const now = performance.now(); + + // don't commit on every animation frame, only commit it at a + // set interval to avoid React's nested update limit + if (now - lastDragTimeCommitRef.current >= DRAG_STATE_COMMIT_MS) { + lastDragTimeCommitRef.current = now; + pendingDragTimeRef.current = null; + setDraggableElementTime(newTime); + } else { + // Hold the newest value; handleMouseUp flushes it so the + // release still lands exactly where the handle was dropped. + pendingDragTimeRef.current = newTime; + } } if (draggingAtTopEdge || draggingAtBottomEdge) { diff --git a/web/src/hooks/use-user-interaction.ts b/web/src/hooks/use-user-interaction.ts index 2c335eee82..f51a1871e2 100644 --- a/web/src/hooks/use-user-interaction.ts +++ b/web/src/hooks/use-user-interaction.ts @@ -8,6 +8,7 @@ function useUserInteraction({ elementRef }: UseUserInteractionProps) { const [userInteracting, setUserInteracting] = useState(false); const interactionTimeout = useRef(undefined); const isProgrammaticScroll = useRef(false); + const userInteractingRef = useRef(false); const setProgrammaticScroll = useCallback(() => { isProgrammaticScroll.current = true; @@ -16,13 +17,18 @@ function useUserInteraction({ elementRef }: UseUserInteractionProps) { useEffect(() => { const handleUserInteraction = () => { if (!isProgrammaticScroll.current) { - setUserInteracting(true); + // Only commit state on the leading edge + if (!userInteractingRef.current) { + userInteractingRef.current = true; + setUserInteracting(true); + } if (interactionTimeout.current) { clearTimeout(interactionTimeout.current); } interactionTimeout.current = setTimeout(() => { + userInteractingRef.current = false; setUserInteracting(false); }, 3000); } else { diff --git a/web/src/views/motion-search/MotionSearchView.tsx b/web/src/views/motion-search/MotionSearchView.tsx index faccb54abc..df0d525e9e 100644 --- a/web/src/views/motion-search/MotionSearchView.tsx +++ b/web/src/views/motion-search/MotionSearchView.tsx @@ -51,7 +51,12 @@ import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { getChunkedTimeDay } from "@/utils/timelineUtil"; -import { MotionData, REVIEW_PADDING, ZoomLevel } from "@/types/review"; +import { + MotionData, + REVIEW_PADDING, + ReviewSegment, + ZoomLevel, +} from "@/types/review"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT, @@ -85,6 +90,7 @@ type MotionSearchViewProps = { }; const DEFAULT_EXPORT_WINDOW_SECONDS = 60; +const NO_REVIEW_EVENTS: ReviewSegment[] = []; export default function MotionSearchView({ config, @@ -514,6 +520,12 @@ export default function MotionSearchView({ : null, ); + const timelineMotionEvents = useMemo(() => motionData ?? [], [motionData]); + const timelineNoRecordings = useMemo( + () => noRecordings ?? [], + [noRecordings], + ); + const recordingParams = useMemo( () => ({ before: currentTimeRange.before, @@ -1054,11 +1066,11 @@ export default function MotionSearchView({ showHandlebar={true} handlebarTime={currentTime} setHandlebarTime={setCurrentTime} - events={[]} - motion_events={motionData ?? []} - noRecordingRanges={noRecordings ?? []} + events={NO_REVIEW_EVENTS} + motion_events={timelineMotionEvents} + noRecordingRanges={timelineNoRecordings} contentRef={contentRef} - onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)} + onHandlebarDraggingChange={setScrubbing} showExportHandles={ (exportMode === "timeline" || exportMode === "timeline_multi") && Boolean(exportRange) diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 924538cff5..f51bc6a3c0 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -1199,7 +1199,7 @@ function Timeline({ motion_events={motionData ?? []} noRecordingRanges={noRecordings ?? []} contentRef={contentRef} - onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)} + onHandlebarDraggingChange={setScrubbing} isZooming={isZooming} zoomDirection={zoomDirection} onZoomChange={handleZoomChange}