diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index 25385fa8d..c91e3cc11 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -17,6 +17,7 @@ import { import { HoverCardPortal } from "@radix-ui/react-hover-card"; import scrollIntoView from "scroll-into-view-if-needed"; import { MinimapBounds, Tick, Timestamp } from "./segment-metadata"; +import useTapUtils from "@/hooks/use-tap-utils"; type EventSegmentProps = { events: ReviewSegment[]; @@ -88,6 +89,8 @@ export function EventSegment({ const apiHost = useApiHost(); + const { handleTouchStart } = useTapUtils(); + const eventThumbnail = useMemo(() => { return getEventThumbnail(segmentTime); }, [getEventThumbnail, segmentTime]); @@ -227,6 +230,9 @@ export function EventSegment({ key={`${segmentKey}_${index}_primary_data`} className={`w-full h-2 bg-gradient-to-r ${roundBottomPrimary ? "rounded-bl-full rounded-br-full" : ""} ${roundTopPrimary ? "rounded-tl-full rounded-tr-full" : ""} ${severityColors[severityValue]}`} onClick={segmentClick} + onTouchStart={(event) => + handleTouchStart(event, segmentClick) + } > diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 6cacc0deb..4c6c38409 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -97,7 +97,7 @@ export function MotionReviewTimeline({ showMinimap={showMinimap} minimapStartTime={minimapStartTime} minimapEndTime={minimapEndTime} - contentRef={contentRef} + setHandlebarTime={setHandlebarTime} /> ); }); diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 070b66857..2c7138967 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -1,17 +1,12 @@ import { useEventUtils } from "@/hooks/use-event-utils"; import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { MotionData, ReviewSegment } from "@/types/review"; -import React, { - RefObject, - useCallback, - useEffect, - useMemo, - useRef, -} from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import scrollIntoView from "scroll-into-view-if-needed"; import { MinimapBounds, Tick, Timestamp } from "./segment-metadata"; import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils"; import { isMobile } from "react-device-detect"; +import useTapUtils from "@/hooks/use-tap-utils"; type MotionSegmentProps = { events: ReviewSegment[]; @@ -22,7 +17,7 @@ type MotionSegmentProps = { showMinimap: boolean; minimapStartTime?: number; minimapEndTime?: number; - contentRef: RefObject; + setHandlebarTime?: React.Dispatch>; }; export function MotionSegment({ @@ -34,7 +29,7 @@ export function MotionSegment({ showMinimap, minimapStartTime, minimapEndTime, - contentRef, + setHandlebarTime, }: MotionSegmentProps) { const severityType = "all"; const { @@ -42,20 +37,18 @@ export function MotionSegment({ getReviewed, displaySeverityType, shouldShowRoundedCorners, - getEventStart, } = useEventSegmentUtils(segmentDuration, events, severityType); - const { - getMotionSegmentValue, - getAudioSegmentValue, - interpolateMotionAudioData, - } = useMotionSegmentUtils(segmentDuration, motion_events); + const { getMotionSegmentValue, interpolateMotionAudioData, getMotionStart } = + useMotionSegmentUtils(segmentDuration, motion_events); const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( events, segmentDuration, ); + const { handleTouchStart } = useTapUtils(); + const severity = useMemo( () => getSeverity(segmentTime, displaySeverityType), // we know that these deps are correct @@ -74,19 +67,19 @@ export function MotionSegment({ ); const startTimestamp = useMemo(() => { - const eventStart = getEventStart(segmentTime); + const eventStart = getMotionStart(segmentTime); if (eventStart) { return alignStartDateToTimeline(eventStart); } // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - }, [getEventStart, segmentTime]); + }, [getMotionStart, segmentTime]); const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]); const segmentKey = useMemo(() => segmentTime, [segmentTime]); const maxSegmentWidth = useMemo(() => { - return isMobile ? 15 : 25; + return isMobile ? 30 : 50; }, []); const alignedMinimapStartTime = useMemo( @@ -161,32 +154,10 @@ export function MotionSegment({ }; const segmentClick = useCallback(() => { - if (contentRef.current && startTimestamp) { - const element = contentRef.current.querySelector( - `[data-segment-start="${startTimestamp - segmentDuration}"]`, - ); - if (element instanceof HTMLElement) { - scrollIntoView(element, { - scrollMode: "if-needed", - behavior: "smooth", - }); - element.classList.add( - `outline-severity_${severityType}`, - `shadow-severity_${severityType}`, - ); - element.classList.add("outline-4", "shadow-[0_0_6px_1px]"); - element.classList.remove("outline-0", "shadow-none"); - - // Remove the classes after a short timeout - setTimeout(() => { - element.classList.remove("outline-4", "shadow-[0_0_6px_1px]"); - element.classList.add("outline-0", "shadow-none"); - }, 3000); - } + if (startTimestamp && setHandlebarTime) { + setHandlebarTime(startTimestamp); } - // we know that these deps are correct - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [startTimestamp]); + }, [startTimestamp, setHandlebarTime]); return (
@@ -210,11 +181,12 @@ export function MotionSegment({
-
+
handleTouchStart(event, segmentClick)} style={{ width: interpolateMotionAudioData( getMotionSegmentValue(segmentTime + segmentDuration / 2), @@ -223,27 +195,15 @@ export function MotionSegment({ }} >
-
-
-
-
+
handleTouchStart(event, segmentClick)} style={{ width: interpolateMotionAudioData( getMotionSegmentValue(segmentTime), @@ -252,19 +212,6 @@ export function MotionSegment({ }} >
-
-
-
diff --git a/web/src/components/timeline/ReviewTimeline.tsx b/web/src/components/timeline/ReviewTimeline.tsx index bd5fdf4dd..09b962755 100644 --- a/web/src/components/timeline/ReviewTimeline.tsx +++ b/web/src/components/timeline/ReviewTimeline.tsx @@ -44,7 +44,7 @@ export function ReviewTimeline({ onTouchMove={handleMouseMove} onMouseUp={handleMouseUp} onTouchEnd={handleMouseUp} - className={`relative h-full overflow-y-scroll no-scrollbar bg-secondary ${ + className={`relative h-full overflow-y-auto no-scrollbar bg-secondary ${ isDragging && showHandlebar ? "cursor-grabbing" : "cursor-auto" }`} > @@ -64,7 +64,7 @@ export function ReviewTimeline({ >
; @@ -34,15 +34,55 @@ function useDraggableHandler({ isDragging, setIsDragging, }: DragHandlerProps) { + const [clientYPosition, setClientYPosition] = useState(null); + + const draggingAtTopEdge = useMemo(() => { + if (clientYPosition && timelineRef.current) { + return ( + clientYPosition - timelineRef.current.offsetTop < + timelineRef.current.clientHeight * 0.03 && isDragging + ); + } + }, [clientYPosition, timelineRef, isDragging]); + + const draggingAtBottomEdge = useMemo(() => { + if (clientYPosition && timelineRef.current) { + return ( + clientYPosition > + (timelineRef.current.clientHeight + timelineRef.current.offsetTop) * + 0.97 && isDragging + ); + } + }, [clientYPosition, timelineRef, isDragging]); + + const getClientYPosition = useCallback( + ( + e: React.MouseEvent | React.TouchEvent, + ) => { + let clientY; + if (isMobile && e.nativeEvent instanceof TouchEvent) { + clientY = e.nativeEvent.touches[0].clientY; + } else if (e.nativeEvent instanceof MouseEvent) { + clientY = e.nativeEvent.clientY; + } + + if (clientY) { + setClientYPosition(clientY); + } + }, + [setClientYPosition], + ); + const handleMouseDown = useCallback( ( e: React.MouseEvent | React.TouchEvent, ) => { e.preventDefault(); e.stopPropagation(); + getClientYPosition(e); setIsDragging(true); }, - [setIsDragging], + [setIsDragging, getClientYPosition], ); const handleMouseUp = useCallback( @@ -84,7 +124,7 @@ function useDraggableHandler({ ).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", - ...(segmentDuration < 60 && { second: "2-digit" }), + ...(segmentDuration < 60 && isDesktop && { second: "2-digit" }), }); if (scrollTimeline) { scrollIntoView(thumb, { @@ -115,20 +155,24 @@ function useDraggableHandler({ return; } - let clientY; - if (isMobile && e.nativeEvent instanceof TouchEvent) { - clientY = e.nativeEvent.touches[0].clientY; - } else if (e.nativeEvent instanceof MouseEvent) { - clientY = e.nativeEvent.clientY; - } + getClientYPosition(e); + }, - e.preventDefault(); - e.stopPropagation(); + [contentRef, scrollTimeRef, timelineRef, getClientYPosition], + ); - if (showHandlebar && isDragging && clientY) { + useEffect(() => { + let animationFrameId: number | null = null; + + const handleScroll = () => { + if ( + timelineRef.current && + showHandlebar && + isDragging && + clientYPosition + ) { const { scrollHeight: timelineHeight, - clientHeight: visibleTimelineHeight, scrollTop: scrolled, offsetTop: timelineTop, } = timelineRef.current; @@ -139,10 +183,11 @@ function useDraggableHandler({ const parentScrollTop = getCumulativeScrollTop(timelineRef.current); const newHandlePosition = Math.min( - visibleTimelineHeight + parentScrollTop, + segmentHeight * (timelineDuration / segmentDuration) - + segmentHeight * 2, Math.max( segmentHeight + scrolled, - clientY - timelineTop + parentScrollTop, + clientYPosition - timelineTop + parentScrollTop, ), ); @@ -151,14 +196,24 @@ function useDraggableHandler({ timelineStart - segmentIndex * segmentDuration, ); - const scrollTimeline = - clientY < visibleTimelineHeight * 0.1 || - clientY > visibleTimelineHeight * 0.9; + if (draggingAtTopEdge || draggingAtBottomEdge) { + let newPosition = clientYPosition; + + if (draggingAtTopEdge) { + newPosition = scrolled - segmentHeight; + timelineRef.current.scrollTop = newPosition; + } + + if (draggingAtBottomEdge) { + newPosition = scrolled + segmentHeight; + timelineRef.current.scrollTop = newPosition; + } + } updateHandlebarPosition( newHandlePosition - segmentHeight, segmentStartTime, - scrollTimeline, + false, false, ); @@ -168,22 +223,41 @@ function useDraggableHandler({ (newHandlePosition / segmentHeight) * segmentDuration, ); } + + if (draggingAtTopEdge || draggingAtBottomEdge) { + animationFrameId = requestAnimationFrame(handleScroll); + } } - }, + }; + + const startScroll = () => { + if (isDragging) { + handleScroll(); + } + }; + + const stopScroll = () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + } + }; + + startScroll(); + + return stopScroll; // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps - [ - isDragging, - contentRef, - segmentDuration, - showHandlebar, - timelineDuration, - timelineStart, - updateHandlebarPosition, - alignStartDateToTimeline, - getCumulativeScrollTop, - ], - ); + }, [ + clientYPosition, + isDragging, + segmentDuration, + timelineStart, + timelineDuration, + timelineRef, + draggingAtTopEdge, + draggingAtBottomEdge, + showHandlebar, + ]); useEffect(() => { if ( diff --git a/web/src/hooks/use-motion-segment-utils.ts b/web/src/hooks/use-motion-segment-utils.ts index 7f10069ef..514f91c0c 100644 --- a/web/src/hooks/use-motion-segment-utils.ts +++ b/web/src/hooks/use-motion-segment-utils.ts @@ -66,9 +66,25 @@ export const useMotionSegmentUtils = ( [motion_events, getSegmentStart, getSegmentEnd], ); + const getMotionStart = useCallback( + (time: number): number => { + const matchingEvent = motion_events.find((event) => { + return ( + time >= getSegmentStart(event.start_time) && + time < getSegmentEnd(event.start_time) && + event.motion + ); + }); + + return matchingEvent?.start_time ?? 0; + }, + [motion_events, getSegmentStart, getSegmentEnd], + ); + return { getMotionSegmentValue, getAudioSegmentValue, interpolateMotionAudioData, + getMotionStart, }; }; diff --git a/web/src/hooks/use-tap-utils.ts b/web/src/hooks/use-tap-utils.ts new file mode 100644 index 000000000..6cdb0d40b --- /dev/null +++ b/web/src/hooks/use-tap-utils.ts @@ -0,0 +1,36 @@ +import { useCallback } from "react"; + +interface TapUtils { + handleTouchStart: ( + event: React.TouchEvent, + onClick: () => void, + ) => void; +} + +const useTapUtils = (): TapUtils => { + const handleTouchStart = useCallback( + (event: React.TouchEvent, onClick: () => void) => { + event.preventDefault(); + + const element = event.target as Element; + const { clientX, clientY } = event.changedTouches[0]; + + // Determine if the touch is within the element's bounds + const rect = element.getBoundingClientRect(); + if ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + // Call the onClick handler + onClick(); + } + }, + [], + ); + + return { handleTouchStart }; +}; + +export default useTapUtils; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index e36321061..82069cd06 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -658,38 +658,40 @@ function MotionReview({ return ( <> -
- {reviewCameras.map((camera) => { - let grow; - const aspectRatio = camera.detect.width / camera.detect.height; - if (aspectRatio > 2) { - grow = "sm:col-span-2 aspect-wide"; - } else if (aspectRatio < 1) { - grow = "md:row-span-2 md:h-full aspect-tall"; - } else { - grow = "aspect-video"; - } - return ( - { - videoPlayersRef.current[camera.name] = controller; - setPlayerReady(true); - }} - onClick={() => - onSelectReview(`motion,${camera.name},${currentTime}`, false) - } - /> - ); - })} +
+
+ {reviewCameras.map((camera) => { + let grow; + const aspectRatio = camera.detect.width / camera.detect.height; + if (aspectRatio > 2) { + grow = "sm:col-span-2 aspect-wide"; + } else if (aspectRatio < 1) { + grow = "md:row-span-2 md:h-full aspect-tall"; + } else { + grow = "aspect-video"; + } + return ( + { + videoPlayersRef.current[camera.name] = controller; + setPlayerReady(true); + }} + onClick={() => + onSelectReview(`motion,${camera.name},${currentTime}`, false) + } + /> + ); + })} +