Timeline fixes and export handles (#10522)

* select an export range from timeline

* height tweak
This commit is contained in:
Josh Hawkins 2024-03-18 15:58:54 -05:00 committed by GitHub
parent 880bae1eb2
commit d249e5b27f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 771 additions and 249 deletions

View File

@ -1,4 +1,4 @@
import useDraggableHandler from "@/hooks/use-handle-dragging"; import useDraggableElement from "@/hooks/use-draggable-element";
import { import {
useEffect, useEffect,
useCallback, useCallback,
@ -8,7 +8,7 @@ import {
RefObject, RefObject,
} from "react"; } from "react";
import EventSegment from "./EventSegment"; import EventSegment from "./EventSegment";
import { useEventUtils } from "@/hooks/use-event-utils"; import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewSegment, ReviewSeverity } from "@/types/review";
import ReviewTimeline from "./ReviewTimeline"; import ReviewTimeline from "./ReviewTimeline";
@ -23,6 +23,11 @@ export type EventReviewTimelineProps = {
showMinimap?: boolean; showMinimap?: boolean;
minimapStartTime?: number; minimapStartTime?: number;
minimapEndTime?: number; minimapEndTime?: number;
showExportHandles?: boolean;
exportStartTime?: number;
exportEndTime?: number;
setExportStartTime?: React.Dispatch<React.SetStateAction<number>>;
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
events: ReviewSegment[]; events: ReviewSegment[];
severityType: ReviewSeverity; severityType: ReviewSeverity;
contentRef: RefObject<HTMLDivElement>; contentRef: RefObject<HTMLDivElement>;
@ -40,46 +45,112 @@ export function EventReviewTimeline({
showMinimap = false, showMinimap = false,
minimapStartTime, minimapStartTime,
minimapEndTime, minimapEndTime,
showExportHandles = false,
exportStartTime,
exportEndTime,
setExportStartTime,
setExportEndTime,
events, events,
severityType, severityType,
contentRef, contentRef,
onHandlebarDraggingChange, onHandlebarDraggingChange,
}: EventReviewTimelineProps) { }: EventReviewTimelineProps) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const handlebarRef = useRef<HTMLDivElement>(null); const [exportStartPosition, setExportStartPosition] = useState(0);
const [exportEndPosition, setExportEndPosition] = useState(0);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const handlebarRef = useRef<HTMLDivElement>(null);
const handlebarTimeRef = useRef<HTMLDivElement>(null); const handlebarTimeRef = useRef<HTMLDivElement>(null);
const exportStartRef = useRef<HTMLDivElement>(null);
const exportStartTimeRef = useRef<HTMLDivElement>(null);
const exportEndRef = useRef<HTMLDivElement>(null);
const exportEndTimeRef = useRef<HTMLDivElement>(null);
const timelineDuration = useMemo( const timelineDuration = useMemo(
() => timelineStart - timelineEnd, () => timelineStart - timelineEnd,
[timelineEnd, timelineStart], [timelineEnd, timelineStart],
); );
const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( const { alignStartDateToTimeline, alignEndDateToTimeline } =
events, useTimelineUtils(segmentDuration);
segmentDuration,
);
const timelineStartAligned = useMemo( const timelineStartAligned = useMemo(
() => alignStartDateToTimeline(timelineStart), () => alignStartDateToTimeline(timelineStart),
[timelineStart, alignStartDateToTimeline], [timelineStart, alignStartDateToTimeline],
); );
const { handleMouseDown, handleMouseUp, handleMouseMove } = const paddedExportStartTime = useMemo(() => {
useDraggableHandler({ if (exportStartTime) {
return alignStartDateToTimeline(exportStartTime) + segmentDuration;
}
}, [exportStartTime, segmentDuration, alignStartDateToTimeline]);
const paddedExportEndTime = useMemo(() => {
if (exportEndTime) {
return alignEndDateToTimeline(exportEndTime) - segmentDuration * 2;
}
}, [exportEndTime, segmentDuration, alignEndDateToTimeline]);
const {
handleMouseDown: handlebarMouseDown,
handleMouseUp: handlebarMouseUp,
handleMouseMove: handlebarMouseMove,
} = useDraggableElement({
contentRef, contentRef,
timelineRef, timelineRef,
handlebarRef, draggableElementRef: handlebarRef,
alignStartDateToTimeline,
alignEndDateToTimeline,
segmentDuration, segmentDuration,
showHandlebar, showDraggableElement: showHandlebar,
handlebarTime, draggableElementTime: handlebarTime,
setHandlebarTime, setDraggableElementTime: setHandlebarTime,
timelineDuration, timelineDuration,
timelineStartAligned, timelineStartAligned,
isDragging, isDragging,
setIsDragging, setIsDragging,
handlebarTimeRef, draggableElementTimeRef: handlebarTimeRef,
});
const {
handleMouseDown: exportStartMouseDown,
handleMouseUp: exportStartMouseUp,
handleMouseMove: exportStartMouseMove,
} = useDraggableElement({
contentRef,
timelineRef,
draggableElementRef: exportStartRef,
segmentDuration,
showDraggableElement: showExportHandles,
draggableElementTime: exportStartTime,
draggableElementLatestTime: paddedExportEndTime,
setDraggableElementTime: setExportStartTime,
timelineDuration,
timelineStartAligned,
isDragging,
setIsDragging,
draggableElementTimeRef: exportStartTimeRef,
setDraggableElementPosition: setExportStartPosition,
});
const {
handleMouseDown: exportEndMouseDown,
handleMouseUp: exportEndMouseUp,
handleMouseMove: exportEndMouseMove,
} = useDraggableElement({
contentRef,
timelineRef,
draggableElementRef: exportEndRef,
segmentDuration,
showDraggableElement: showExportHandles,
draggableElementTime: exportEndTime,
draggableElementEarliestTime: paddedExportStartTime,
setDraggableElementTime: setExportEndTime,
timelineDuration,
timelineStartAligned,
isDragging,
setIsDragging,
draggableElementTimeRef: exportEndTimeRef,
setDraggableElementPosition: setExportEndPosition,
}); });
// Generate segments for the timeline // Generate segments for the timeline
@ -145,12 +216,26 @@ export function EventReviewTimeline({
timelineRef={timelineRef} timelineRef={timelineRef}
handlebarRef={handlebarRef} handlebarRef={handlebarRef}
handlebarTimeRef={handlebarTimeRef} handlebarTimeRef={handlebarTimeRef}
handleMouseMove={handleMouseMove} handlebarMouseMove={handlebarMouseMove}
handleMouseUp={handleMouseUp} handlebarMouseUp={handlebarMouseUp}
handleMouseDown={handleMouseDown} handlebarMouseDown={handlebarMouseDown}
segmentDuration={segmentDuration} segmentDuration={segmentDuration}
timelineDuration={timelineDuration}
showHandlebar={showHandlebar} showHandlebar={showHandlebar}
isDragging={isDragging} isDragging={isDragging}
exportStartMouseMove={exportStartMouseMove}
exportStartMouseUp={exportStartMouseUp}
exportStartMouseDown={exportStartMouseDown}
exportEndMouseMove={exportEndMouseMove}
exportEndMouseUp={exportEndMouseUp}
exportEndMouseDown={exportEndMouseDown}
showExportHandles={showExportHandles}
exportStartRef={exportStartRef}
exportStartTimeRef={exportStartTimeRef}
exportEndRef={exportEndRef}
exportEndTimeRef={exportEndTimeRef}
exportStartPosition={exportStartPosition}
exportEndPosition={exportEndPosition}
> >
{segments} {segments}
</ReviewTimeline> </ReviewTimeline>

View File

@ -1,5 +1,5 @@
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { useEventUtils } from "@/hooks/use-event-utils"; import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewSegment, ReviewSeverity } from "@/types/review";
import React, { import React, {
@ -53,10 +53,8 @@ export function EventSegment({
getEventThumbnail, getEventThumbnail,
} = useEventSegmentUtils(segmentDuration, events, severityType); } = useEventSegmentUtils(segmentDuration, events, severityType);
const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( const { alignStartDateToTimeline, alignEndDateToTimeline } =
events, useTimelineUtils(segmentDuration);
segmentDuration,
);
const severity = useMemo( const severity = useMemo(
() => getSeverity(segmentTime, displaySeverityType), () => getSeverity(segmentTime, displaySeverityType),
@ -155,7 +153,7 @@ export function EventSegment({
: "" : ""
} ${ } ${
isFirstSegmentInMinimap || isLastSegmentInMinimap isFirstSegmentInMinimap || isLastSegmentInMinimap
? "relative h-2 border-b-2 border-gray-500" ? "relative h-2 border-b-2 border-neutral-600"
: "" : ""
}`; }`;
@ -236,7 +234,7 @@ export function EventSegment({
key={`${segmentKey}_${index}_primary_data`} 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]}`} 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} onClick={segmentClick}
onTouchStart={(event) => onTouchEnd={(event) =>
handleTouchStart(event, segmentClick) handleTouchStart(event, segmentClick)
} }
></div> ></div>

View File

@ -1,4 +1,4 @@
import useDraggableHandler from "@/hooks/use-handle-dragging"; import useDraggableElement from "@/hooks/use-draggable-element";
import { import {
useEffect, useEffect,
useCallback, useCallback,
@ -8,7 +8,7 @@ import {
RefObject, RefObject,
} from "react"; } from "react";
import MotionSegment from "./MotionSegment"; import MotionSegment from "./MotionSegment";
import { useEventUtils } from "@/hooks/use-event-utils"; import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review"; import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
import ReviewTimeline from "./ReviewTimeline"; import ReviewTimeline from "./ReviewTimeline";
@ -23,6 +23,11 @@ export type MotionReviewTimelineProps = {
showMinimap?: boolean; showMinimap?: boolean;
minimapStartTime?: number; minimapStartTime?: number;
minimapEndTime?: number; minimapEndTime?: number;
showExportHandles?: boolean;
exportStartTime?: number;
exportEndTime?: number;
setExportStartTime?: React.Dispatch<React.SetStateAction<number>>;
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
events: ReviewSegment[]; events: ReviewSegment[];
motion_events: MotionData[]; motion_events: MotionData[];
severityType: ReviewSeverity; severityType: ReviewSeverity;
@ -41,46 +46,112 @@ export function MotionReviewTimeline({
showMinimap = false, showMinimap = false,
minimapStartTime, minimapStartTime,
minimapEndTime, minimapEndTime,
showExportHandles = false,
exportStartTime,
exportEndTime,
setExportStartTime,
setExportEndTime,
events, events,
motion_events, motion_events,
contentRef, contentRef,
onHandlebarDraggingChange, onHandlebarDraggingChange,
}: MotionReviewTimelineProps) { }: MotionReviewTimelineProps) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const handlebarRef = useRef<HTMLDivElement>(null); const [exportStartPosition, setExportStartPosition] = useState(0);
const [exportEndPosition, setExportEndPosition] = useState(0);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const handlebarRef = useRef<HTMLDivElement>(null);
const handlebarTimeRef = useRef<HTMLDivElement>(null); const handlebarTimeRef = useRef<HTMLDivElement>(null);
const exportStartRef = useRef<HTMLDivElement>(null);
const exportStartTimeRef = useRef<HTMLDivElement>(null);
const exportEndRef = useRef<HTMLDivElement>(null);
const exportEndTimeRef = useRef<HTMLDivElement>(null);
const timelineDuration = useMemo( const timelineDuration = useMemo(
() => timelineStart - timelineEnd + 4 * segmentDuration, () => timelineStart - timelineEnd + 4 * segmentDuration,
[timelineEnd, timelineStart, segmentDuration], [timelineEnd, timelineStart, segmentDuration],
); );
const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( const { alignStartDateToTimeline, alignEndDateToTimeline } =
events, useTimelineUtils(segmentDuration);
segmentDuration,
);
const timelineStartAligned = useMemo( const timelineStartAligned = useMemo(
() => alignStartDateToTimeline(timelineStart) + 2 * segmentDuration, () => alignStartDateToTimeline(timelineStart) + 2 * segmentDuration,
[timelineStart, alignStartDateToTimeline, segmentDuration], [timelineStart, alignStartDateToTimeline, segmentDuration],
); );
const { handleMouseDown, handleMouseUp, handleMouseMove } = const paddedExportStartTime = useMemo(() => {
useDraggableHandler({ if (exportStartTime) {
return alignStartDateToTimeline(exportStartTime) + segmentDuration;
}
}, [exportStartTime, segmentDuration, alignStartDateToTimeline]);
const paddedExportEndTime = useMemo(() => {
if (exportEndTime) {
return alignEndDateToTimeline(exportEndTime) - segmentDuration * 2;
}
}, [exportEndTime, segmentDuration, alignEndDateToTimeline]);
const {
handleMouseDown: handlebarMouseDown,
handleMouseUp: handlebarMouseUp,
handleMouseMove: handlebarMouseMove,
} = useDraggableElement({
contentRef, contentRef,
timelineRef, timelineRef,
handlebarRef, draggableElementRef: handlebarRef,
alignStartDateToTimeline,
alignEndDateToTimeline,
segmentDuration, segmentDuration,
showHandlebar, showDraggableElement: showHandlebar,
handlebarTime, draggableElementTime: handlebarTime,
setHandlebarTime, setDraggableElementTime: setHandlebarTime,
timelineDuration, timelineDuration,
timelineStartAligned, timelineStartAligned,
isDragging, isDragging,
setIsDragging, setIsDragging,
handlebarTimeRef, draggableElementTimeRef: handlebarTimeRef,
});
const {
handleMouseDown: exportStartMouseDown,
handleMouseUp: exportStartMouseUp,
handleMouseMove: exportStartMouseMove,
} = useDraggableElement({
contentRef,
timelineRef,
draggableElementRef: exportStartRef,
segmentDuration,
showDraggableElement: showExportHandles,
draggableElementTime: exportStartTime,
draggableElementLatestTime: paddedExportEndTime,
setDraggableElementTime: setExportStartTime,
timelineDuration,
timelineStartAligned,
isDragging,
setIsDragging,
draggableElementTimeRef: exportStartTimeRef,
setDraggableElementPosition: setExportStartPosition,
});
const {
handleMouseDown: exportEndMouseDown,
handleMouseUp: exportEndMouseUp,
handleMouseMove: exportEndMouseMove,
} = useDraggableElement({
contentRef,
timelineRef,
draggableElementRef: exportEndRef,
segmentDuration,
showDraggableElement: showExportHandles,
draggableElementTime: exportEndTime,
draggableElementEarliestTime: paddedExportStartTime,
setDraggableElementTime: setExportEndTime,
timelineDuration,
timelineStartAligned,
isDragging,
setIsDragging,
draggableElementTimeRef: exportEndTimeRef,
setDraggableElementPosition: setExportEndPosition,
}); });
// Generate segments for the timeline // Generate segments for the timeline
@ -147,12 +218,26 @@ export function MotionReviewTimeline({
timelineRef={timelineRef} timelineRef={timelineRef}
handlebarRef={handlebarRef} handlebarRef={handlebarRef}
handlebarTimeRef={handlebarTimeRef} handlebarTimeRef={handlebarTimeRef}
handleMouseMove={handleMouseMove} handlebarMouseMove={handlebarMouseMove}
handleMouseUp={handleMouseUp} handlebarMouseUp={handlebarMouseUp}
handleMouseDown={handleMouseDown} handlebarMouseDown={handlebarMouseDown}
segmentDuration={segmentDuration} segmentDuration={segmentDuration}
timelineDuration={timelineDuration}
showHandlebar={showHandlebar} showHandlebar={showHandlebar}
isDragging={isDragging} isDragging={isDragging}
exportStartMouseMove={exportStartMouseMove}
exportStartMouseUp={exportStartMouseUp}
exportStartMouseDown={exportStartMouseDown}
exportEndMouseMove={exportEndMouseMove}
exportEndMouseUp={exportEndMouseUp}
exportEndMouseDown={exportEndMouseDown}
showExportHandles={showExportHandles}
exportStartRef={exportStartRef}
exportStartTimeRef={exportStartTimeRef}
exportEndRef={exportEndRef}
exportEndTimeRef={exportEndTimeRef}
exportStartPosition={exportStartPosition}
exportEndPosition={exportEndPosition}
> >
{segments} {segments}
</ReviewTimeline> </ReviewTimeline>

View File

@ -1,4 +1,4 @@
import { useEventUtils } from "@/hooks/use-event-utils"; import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
import { MotionData, ReviewSegment } from "@/types/review"; import { MotionData, ReviewSegment } from "@/types/review";
import React, { useCallback, useEffect, useMemo, useRef } from "react"; import React, { useCallback, useEffect, useMemo, useRef } from "react";
@ -42,10 +42,8 @@ export function MotionSegment({
const { getMotionSegmentValue, interpolateMotionAudioData } = const { getMotionSegmentValue, interpolateMotionAudioData } =
useMotionSegmentUtils(segmentDuration, motion_events); useMotionSegmentUtils(segmentDuration, motion_events);
const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils( const { alignStartDateToTimeline, alignEndDateToTimeline } =
events, useTimelineUtils(segmentDuration);
segmentDuration,
);
const { handleTouchStart } = useTapUtils(); const { handleTouchStart } = useTapUtils();
@ -180,7 +178,7 @@ export function MotionSegment({
key={segmentKey} key={segmentKey}
className={segmentClasses} className={segmentClasses}
onClick={segmentClick} onClick={segmentClick}
onTouchStart={(event) => handleTouchStart(event, segmentClick)} onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
> >
<MinimapBounds <MinimapBounds
isFirstSegmentInMinimap={isFirstSegmentInMinimap} isFirstSegmentInMinimap={isFirstSegmentInMinimap}

View File

@ -1,28 +1,75 @@
import { ReactNode, RefObject } from "react"; import { DraggableElement } from "@/types/draggable-element";
import {
ReactNode,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isIOS, isMobile } from "react-device-detect"; import { isIOS, isMobile } from "react-device-detect";
export type ReviewTimelineProps = { export type ReviewTimelineProps = {
timelineRef: RefObject<HTMLDivElement>; timelineRef: RefObject<HTMLDivElement>;
handlebarRef: RefObject<HTMLDivElement>; handlebarRef: RefObject<HTMLDivElement>;
handlebarTimeRef: RefObject<HTMLDivElement>; handlebarTimeRef: RefObject<HTMLDivElement>;
handleMouseMove: ( handlebarMouseMove: (
e: e:
| React.MouseEvent<HTMLDivElement, MouseEvent> | React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>, | React.TouchEvent<HTMLDivElement>,
) => void; ) => void;
handleMouseUp: ( handlebarMouseUp: (
e: e:
| React.MouseEvent<HTMLDivElement, MouseEvent> | React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>, | React.TouchEvent<HTMLDivElement>,
) => void; ) => void;
handleMouseDown: ( handlebarMouseDown: (
e: e:
| React.MouseEvent<HTMLDivElement, MouseEvent> | React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>, | React.TouchEvent<HTMLDivElement>,
) => void; ) => void;
segmentDuration: number; segmentDuration: number;
timelineDuration: number;
showHandlebar: boolean; showHandlebar: boolean;
showExportHandles: boolean;
exportStartRef: RefObject<HTMLDivElement>;
exportStartTimeRef: RefObject<HTMLDivElement>;
exportEndRef: RefObject<HTMLDivElement>;
exportEndTimeRef: RefObject<HTMLDivElement>;
exportStartMouseMove: (
e:
| React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>,
) => void;
exportStartMouseUp: (
e:
| React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>,
) => void;
exportStartMouseDown: (
e:
| React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>,
) => void;
exportEndMouseMove: (
e:
| React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>,
) => void;
exportEndMouseUp: (
e:
| React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>,
) => void;
exportEndMouseDown: (
e:
| React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>,
) => void;
isDragging: boolean; isDragging: boolean;
exportStartPosition?: number;
exportEndPosition?: number;
children: ReactNode; children: ReactNode;
}; };
@ -30,14 +77,156 @@ export function ReviewTimeline({
timelineRef, timelineRef,
handlebarRef, handlebarRef,
handlebarTimeRef, handlebarTimeRef,
handleMouseMove, handlebarMouseMove,
handleMouseUp, handlebarMouseUp,
handleMouseDown, handlebarMouseDown,
segmentDuration, segmentDuration,
timelineDuration,
showHandlebar = false, showHandlebar = false,
showExportHandles = false,
exportStartRef,
exportStartTimeRef,
exportEndRef,
exportEndTimeRef,
exportStartMouseMove,
exportStartMouseUp,
exportStartMouseDown,
exportEndMouseMove,
exportEndMouseUp,
exportEndMouseDown,
isDragging, isDragging,
exportStartPosition,
exportEndPosition,
children, children,
}: ReviewTimelineProps) { }: ReviewTimelineProps) {
const exportSectionRef = useRef<HTMLDivElement>(null);
const segmentHeight = useMemo(() => {
if (timelineRef.current) {
const { scrollHeight: timelineHeight } =
timelineRef.current as HTMLDivElement;
return timelineHeight / (timelineDuration / segmentDuration);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [segmentDuration, timelineDuration, timelineRef, showExportHandles]);
const [draggableElementType, setDraggableElementType] =
useState<DraggableElement>();
const handleHandlebar = useCallback(
(
e:
| React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>,
) => {
setDraggableElementType("handlebar");
handlebarMouseDown(e);
},
[handlebarMouseDown],
);
const handleExportStart = useCallback(
(
e:
| React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>,
) => {
setDraggableElementType("export_start");
exportStartMouseDown(e);
},
[exportStartMouseDown],
);
const handleExportEnd = useCallback(
(
e:
| React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>,
) => {
setDraggableElementType("export_end");
exportEndMouseDown(e);
},
[exportEndMouseDown],
);
const handleMouseMove = useCallback(
(
e:
| React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>,
) => {
switch (draggableElementType) {
case "export_start":
exportStartMouseMove(e);
break;
case "export_end":
exportEndMouseMove(e);
break;
case "handlebar":
handlebarMouseMove(e);
break;
default:
break;
}
},
[
draggableElementType,
exportStartMouseMove,
exportEndMouseMove,
handlebarMouseMove,
],
);
const handleMouseUp = useCallback(
(
e:
| React.MouseEvent<HTMLDivElement, MouseEvent>
| React.TouchEvent<HTMLDivElement>,
) => {
switch (draggableElementType) {
case "export_start":
exportStartMouseUp(e);
break;
case "export_end":
exportEndMouseUp(e);
break;
case "handlebar":
handlebarMouseUp(e);
break;
default:
break;
}
},
[
draggableElementType,
exportStartMouseUp,
exportEndMouseUp,
handlebarMouseUp,
],
);
useEffect(() => {
if (
exportSectionRef.current &&
segmentHeight &&
exportStartPosition &&
exportEndPosition
) {
exportSectionRef.current.style.top = `${exportEndPosition + segmentHeight}px`;
exportSectionRef.current.style.height = `${exportStartPosition - exportEndPosition + segmentHeight / 2}px`;
}
}, [
showExportHandles,
segmentHeight,
timelineRef,
exportStartPosition,
exportEndPosition,
]);
return ( return (
<div <div
ref={timelineRef} ref={timelineRef}
@ -46,7 +235,9 @@ export function ReviewTimeline({
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onTouchEnd={handleMouseUp} onTouchEnd={handleMouseUp}
className={`relative h-full overflow-y-auto no-scrollbar bg-secondary ${ className={`relative h-full overflow-y-auto no-scrollbar bg-secondary ${
isDragging && showHandlebar ? "cursor-grabbing" : "cursor-auto" isDragging && (showHandlebar || showExportHandles)
? "cursor-grabbing"
: "cursor-auto"
}`} }`}
> >
<div className="flex flex-col relative"> <div className="flex flex-col relative">
@ -62,8 +253,8 @@ export function ReviewTimeline({
> >
<div <div
className="flex items-center justify-center touch-none select-none" className="flex items-center justify-center touch-none select-none"
onMouseDown={handleMouseDown} onMouseDown={handleHandlebar}
onTouchStart={handleMouseDown} onTouchStart={handleHandlebar}
> >
<div <div
className={`relative w-full ${ className={`relative w-full ${
@ -73,20 +264,90 @@ export function ReviewTimeline({
<div <div
className={`bg-destructive rounded-full mx-auto ${ className={`bg-destructive rounded-full mx-auto ${
segmentDuration < 60 ? "w-12 md:w-20" : "w-12 md:w-16" segmentDuration < 60 ? "w-12 md:w-20" : "w-12 md:w-16"
} h-5 ${isDragging && isMobile ? "fixed top-[18px] left-1/2 transform -translate-x-1/2 z-20 w-[30%] h-[30px] bg-destructive/80" : "static"} flex items-center justify-center`} } h-5 ${isDragging && isMobile && draggableElementType == "handlebar" ? "fixed top-[18px] left-1/2 transform -translate-x-1/2 z-20 w-[30%] h-[30px] bg-destructive/80" : "static"} flex items-center justify-center`}
> >
<div <div
ref={handlebarTimeRef} ref={handlebarTimeRef}
className={`text-white ${isDragging && isMobile ? "text-lg" : "text-[8px] md:text-xs"} z-10`} className={`text-white pointer-events-none ${isDragging && isMobile && draggableElementType == "handlebar" ? "text-lg" : "text-[8px] md:text-xs"} z-10`}
></div> ></div>
</div> </div>
<div <div
className={`absolute h-1 w-full bg-destructive ${isDragging && isMobile ? "top-1" : "top-1/2 transform -translate-y-1/2"}`} className={`absolute h-1 w-full bg-destructive ${isDragging && isMobile && draggableElementType == "handlebar" ? "top-1" : "top-1/2 transform -translate-y-1/2"}`}
></div> ></div>
</div> </div>
</div> </div>
</div> </div>
)} )}
{showExportHandles && (
<>
<div
className={`absolute left-0 top-0 ${isDragging && isIOS ? "" : "z-20"} w-full`}
role="scrollbar"
ref={exportEndRef}
>
<div
className="flex items-center justify-center touch-none select-none"
onMouseDown={handleExportEnd}
onTouchStart={handleExportEnd}
>
<div
className={`relative mt-[6.5px] w-full ${
isDragging ? "cursor-grabbing" : "cursor-grab"
}`}
>
<div
className={`bg-selected -mt-4 mx-auto ${
segmentDuration < 60 ? "w-12 md:w-20" : "w-12 md:w-16"
} h-5 ${isDragging && isMobile && draggableElementType == "export_end" ? "fixed mt-0 rounded-full top-[18px] left-1/2 transform -translate-x-1/2 z-20 w-[30%] h-[30px] bg-selected/80" : "rounded-tr-lg rounded-tl-lg static"} flex items-center justify-center`}
>
<div
ref={exportEndTimeRef}
className={`text-white pointer-events-none ${isDragging && isMobile && draggableElementType == "export_end" ? "text-lg mt-0" : "text-[8px] md:text-xs"} z-10`}
></div>
</div>
<div
className={`absolute h-1 w-full bg-selected ${isDragging && isMobile && draggableElementType == "export_end" ? "top-0" : "top-1/2 transform -translate-y-1/2"}`}
></div>
</div>
</div>
</div>
<div
ref={exportSectionRef}
className="bg-selected/50 absolute w-full"
></div>
<div
className={`absolute left-0 top-0 ${isDragging && isIOS ? "" : "z-20"} w-full`}
role="scrollbar"
ref={exportStartRef}
>
<div
className="flex items-center justify-center touch-none select-none"
onMouseDown={handleExportStart}
onTouchStart={handleExportStart}
>
<div
className={`relative -mt-[6.5px] w-full ${
isDragging ? "cursor-grabbing" : "cursor-grab"
}`}
>
<div
className={`absolute h-1 w-full bg-selected ${isDragging && isMobile && draggableElementType == "export_start" ? "top-[12px]" : "top-1/2 transform -translate-y-1/2"}`}
></div>
<div
className={`bg-selected mt-4 mx-auto ${
segmentDuration < 60 ? "w-12 md:w-20" : "w-12 md:w-16"
} h-5 ${isDragging && isMobile && draggableElementType == "export_start" ? "fixed mt-0 rounded-full top-[4px] left-1/2 transform -translate-x-1/2 z-20 w-[30%] h-[30px] bg-selected/80" : "rounded-br-lg rounded-bl-lg static"} flex items-center justify-center`}
>
<div
ref={exportStartTimeRef}
className={`text-white pointer-events-none ${isDragging && isMobile && draggableElementType == "export_start" ? "text-lg mt-0" : "text-[8px] md:text-xs"} z-10`}
></div>
</div>
</div>
</div>
</div>
</>
)}
</div> </div>
); );
} }

View File

@ -32,7 +32,7 @@ export function MinimapBounds({
<> <>
{isFirstSegmentInMinimap && ( {isFirstSegmentInMinimap && (
<div <div
className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] scroll-mt-8" className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] scroll-mt-8 pointer-events-none"
ref={firstMinimapSegmentRef} ref={firstMinimapSegmentRef}
> >
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], { {new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], {
@ -44,7 +44,7 @@ export function MinimapBounds({
)} )}
{isLastSegmentInMinimap && ( {isLastSegmentInMinimap && (
<div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px]"> <div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] pointer-events-none">
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], { {new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
@ -61,14 +61,14 @@ export function Tick({ timestamp, timestampSpread }: TickSegmentProps) {
<div className="absolute"> <div className="absolute">
<div className="flex items-end content-end w-[12px] h-2"> <div className="flex items-end content-end w-[12px] h-2">
<div <div
className={`h-0.5 ${ className={`pointer-events-none h-0.5 ${
timestamp.getMinutes() % timestampSpread === 0 && timestamp.getMinutes() % timestampSpread === 0 &&
timestamp.getSeconds() === 0 timestamp.getSeconds() === 0
? "w-[12px] bg-neutral-600 dark:bg-neutral-500" ? "w-[12px] bg-neutral-600 dark:bg-neutral-500"
: timestamp.getMinutes() % (timestampSpread == 15 ? 5 : 1) === : timestamp.getMinutes() % (timestampSpread == 15 ? 5 : 1) ===
0 && timestamp.getSeconds() === 0 0 && timestamp.getSeconds() === 0
? "w-[8px] bg-neutral-500 dark:bg-neutral-600" // Minor tick mark ? "w-[8px] bg-neutral-500" // Minor tick mark
: "w-[5px] bg-neutral-400 dark:bg-neutral-700" : "w-[5px] bg-neutral-400 dark:bg-neutral-600"
}`} }`}
></div> ></div>
</div> </div>
@ -88,7 +88,7 @@ export function Timestamp({
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
<div <div
key={`${segmentKey}_timestamp`} key={`${segmentKey}_timestamp`}
className="text-[8px] text-neutral-600 dark:text-neutral-500" className="pointer-events-none text-[8px] text-neutral-600 dark:text-neutral-500"
> >
{timestamp.getMinutes() % timestampSpread === 0 && {timestamp.getMinutes() % timestampSpread === 0 &&
timestamp.getSeconds() === 0 && timestamp.getSeconds() === 0 &&

View File

@ -1,41 +1,46 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import scrollIntoView from "scroll-into-view-if-needed"; import scrollIntoView from "scroll-into-view-if-needed";
import { useTimelineUtils } from "./use-timeline-utils";
type DragHandlerProps = { type DraggableElementProps = {
contentRef: React.RefObject<HTMLElement>; contentRef: React.RefObject<HTMLElement>;
timelineRef: React.RefObject<HTMLDivElement>; timelineRef: React.RefObject<HTMLDivElement>;
handlebarRef: React.RefObject<HTMLDivElement>; draggableElementRef: React.RefObject<HTMLDivElement>;
alignStartDateToTimeline: (time: number) => number;
alignEndDateToTimeline: (time: number) => number;
segmentDuration: number; segmentDuration: number;
showHandlebar: boolean; showDraggableElement: boolean;
handlebarTime?: number; draggableElementTime?: number;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>; draggableElementEarliestTime?: number;
handlebarTimeRef: React.MutableRefObject<HTMLDivElement | null>; draggableElementLatestTime?: number;
setDraggableElementTime?: React.Dispatch<React.SetStateAction<number>>;
draggableElementTimeRef: React.MutableRefObject<HTMLDivElement | null>;
timelineDuration: number; timelineDuration: number;
timelineStartAligned: number; timelineStartAligned: number;
isDragging: boolean; isDragging: boolean;
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>; setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
setDraggableElementPosition?: React.Dispatch<React.SetStateAction<number>>;
}; };
function useDraggableHandler({ function useDraggableElement({
contentRef, contentRef,
timelineRef, timelineRef,
handlebarRef, draggableElementRef,
alignStartDateToTimeline,
segmentDuration, segmentDuration,
showHandlebar, showDraggableElement,
handlebarTime, draggableElementTime,
setHandlebarTime, draggableElementEarliestTime,
handlebarTimeRef, draggableElementLatestTime,
setDraggableElementTime,
draggableElementTimeRef,
timelineDuration, timelineDuration,
timelineStartAligned, timelineStartAligned,
isDragging, isDragging,
setIsDragging, setIsDragging,
}: DragHandlerProps) { setDraggableElementPosition,
}: DraggableElementProps) {
const [clientYPosition, setClientYPosition] = useState<number | null>(null); const [clientYPosition, setClientYPosition] = useState<number | null>(null);
const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0);
const { alignStartDateToTimeline } = useTimelineUtils(segmentDuration);
const draggingAtTopEdge = useMemo(() => { const draggingAtTopEdge = useMemo(() => {
if (clientYPosition && timelineRef.current) { if (clientYPosition && timelineRef.current) {
@ -78,17 +83,32 @@ function useDraggableHandler({
( (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>, e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
) => { ) => {
// prevent default only for mouse events
// to avoid chrome/android issues
if (e.nativeEvent instanceof MouseEvent) {
e.preventDefault(); e.preventDefault();
}
e.stopPropagation(); e.stopPropagation();
getClientYPosition(e);
setIsDragging(true); setIsDragging(true);
if (handlebarRef.current && clientYPosition && isDesktop) { let clientY;
const handlebarRect = handlebarRef.current.getBoundingClientRect(); if (isMobile && e.nativeEvent instanceof TouchEvent) {
setInitialClickAdjustment(clientYPosition - handlebarRect.top); clientY = e.nativeEvent.touches[0].clientY;
} else if (e.nativeEvent instanceof MouseEvent) {
clientY = e.nativeEvent.clientY;
}
if (clientY && draggableElementRef.current && isDesktop) {
const draggableElementRect =
draggableElementRef.current.getBoundingClientRect();
if (!isDragging) {
setInitialClickAdjustment(clientY - draggableElementRect.top);
}
setClientYPosition(clientY);
} }
}, },
[setIsDragging, getClientYPosition, handlebarRef, clientYPosition], // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[setIsDragging, draggableElementRef],
); );
const handleMouseUp = useCallback( const handleMouseUp = useCallback(
@ -114,19 +134,36 @@ function useDraggableHandler({
return scrollTop; return scrollTop;
}, []); }, []);
const updateHandlebarPosition = useCallback( const timestampToPixels = useCallback(
(time: number) => {
const { scrollHeight: timelineHeight } =
timelineRef.current as HTMLDivElement;
const segmentHeight =
timelineHeight / (timelineDuration / segmentDuration);
return ((timelineStartAligned - time) / segmentDuration) * segmentHeight;
},
[segmentDuration, timelineRef, timelineStartAligned, timelineDuration],
);
const updateDraggableElementPosition = useCallback(
( (
newHandlePosition: number, newElementPosition: number,
segmentStartTime: number, segmentStartTime: number,
scrollTimeline: boolean, scrollTimeline: boolean,
updateHandle: boolean, updateHandle: boolean,
) => { ) => {
const thumb = handlebarRef.current; const thumb = draggableElementRef.current;
if (thumb) { if (thumb) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
thumb.style.top = `${newHandlePosition}px`; thumb.style.top = `${newElementPosition}px`;
if (handlebarTimeRef.current) { if (setDraggableElementPosition) {
handlebarTimeRef.current.textContent = new Date( setDraggableElementPosition(newElementPosition);
}
if (draggableElementTimeRef.current) {
draggableElementTimeRef.current.textContent = new Date(
segmentStartTime * 1000, segmentStartTime * 1000,
).toLocaleTimeString([], { ).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
@ -143,12 +180,18 @@ function useDraggableHandler({
} }
}); });
if (setHandlebarTime && updateHandle) { if (setDraggableElementTime && updateHandle) {
setHandlebarTime(segmentStartTime); setDraggableElementTime(segmentStartTime);
} }
} }
}, },
[segmentDuration, handlebarTimeRef, handlebarRef, setHandlebarTime], [
segmentDuration,
draggableElementTimeRef,
draggableElementRef,
setDraggableElementTime,
setDraggableElementPosition,
],
); );
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
@ -158,7 +201,7 @@ function useDraggableHandler({
if ( if (
!contentRef.current || !contentRef.current ||
!timelineRef.current || !timelineRef.current ||
!handlebarRef.current !draggableElementRef.current
) { ) {
return; return;
} }
@ -166,7 +209,7 @@ function useDraggableHandler({
getClientYPosition(e); getClientYPosition(e);
}, },
[contentRef, handlebarRef, timelineRef, getClientYPosition], [contentRef, draggableElementRef, timelineRef, getClientYPosition],
); );
useEffect(() => { useEffect(() => {
@ -175,7 +218,7 @@ function useDraggableHandler({
const handleScroll = () => { const handleScroll = () => {
if ( if (
timelineRef.current && timelineRef.current &&
showHandlebar && showDraggableElement &&
isDragging && isDragging &&
clientYPosition clientYPosition
) { ) {
@ -190,13 +233,21 @@ function useDraggableHandler({
const parentScrollTop = getCumulativeScrollTop(timelineRef.current); const parentScrollTop = getCumulativeScrollTop(timelineRef.current);
const newHandlePosition = Math.min( // bottom of timeline
// end of timeline const elementEarliest = draggableElementEarliestTime
segmentHeight * (timelineDuration / segmentDuration) - ? timestampToPixels(draggableElementEarliestTime)
segmentHeight * 2, : segmentHeight * (timelineDuration / segmentDuration) -
segmentHeight * 3;
// top of timeline - default 2 segments added for draggableElement visibility
const elementLatest = draggableElementLatestTime
? timestampToPixels(draggableElementLatestTime)
: segmentHeight * 2 + scrolled;
const newElementPosition = Math.min(
elementEarliest,
Math.max( Math.max(
// start of timeline - 2 segments added for handlebar visibility elementLatest,
segmentHeight * 2 + scrolled,
// current Y position // current Y position
clientYPosition - clientYPosition -
timelineTop + timelineTop +
@ -205,7 +256,7 @@ function useDraggableHandler({
), ),
); );
const segmentIndex = Math.floor(newHandlePosition / segmentHeight); const segmentIndex = Math.floor(newElementPosition / segmentHeight);
const segmentStartTime = alignStartDateToTimeline( const segmentStartTime = alignStartDateToTimeline(
timelineStartAligned - segmentIndex * segmentDuration, timelineStartAligned - segmentIndex * segmentDuration,
); );
@ -224,17 +275,17 @@ function useDraggableHandler({
} }
} }
updateHandlebarPosition( updateDraggableElementPosition(
newHandlePosition - segmentHeight, newElementPosition - segmentHeight,
segmentStartTime, segmentStartTime,
false, false,
false, false,
); );
if (setHandlebarTime) { if (setDraggableElementTime) {
setHandlebarTime( setDraggableElementTime(
timelineStartAligned - timelineStartAligned -
((newHandlePosition - segmentHeight / 2 - 2) / segmentHeight) * ((newElementPosition - segmentHeight / 2 - 2) / segmentHeight) *
segmentDuration, segmentDuration,
); );
} }
@ -264,22 +315,21 @@ function useDraggableHandler({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
clientYPosition, clientYPosition,
isDragging,
segmentDuration, segmentDuration,
timelineStartAligned, timelineStartAligned,
timelineDuration, timelineDuration,
timelineRef, timelineRef,
draggingAtTopEdge, draggingAtTopEdge,
draggingAtBottomEdge, draggingAtBottomEdge,
showHandlebar, showDraggableElement,
]); ]);
useEffect(() => { useEffect(() => {
if ( if (
timelineRef.current && timelineRef.current &&
handlebarRef.current && draggableElementRef.current &&
showHandlebar && showDraggableElement &&
handlebarTime && draggableElementTime &&
!isDragging !isDragging
) { ) {
const { scrollHeight: timelineHeight, scrollTop: scrolled } = const { scrollHeight: timelineHeight, scrollTop: scrolled } =
@ -290,20 +340,30 @@ function useDraggableHandler({
const parentScrollTop = getCumulativeScrollTop(timelineRef.current); const parentScrollTop = getCumulativeScrollTop(timelineRef.current);
const newHandlePosition = const newElementPosition =
((timelineStartAligned - handlebarTime) / segmentDuration) * ((timelineStartAligned - draggableElementTime) / segmentDuration) *
segmentHeight + segmentHeight +
parentScrollTop - parentScrollTop -
scrolled - scrolled -
2; // height of handlebar horizontal line 2; // height of draggableElement horizontal line
updateHandlebarPosition(newHandlePosition, handlebarTime, true, true); updateDraggableElementPosition(
newElementPosition,
draggableElementTime,
true,
true,
);
} }
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [handlebarTime, showHandlebar, handlebarRef, timelineStartAligned]); }, [
draggableElementTime,
showDraggableElement,
draggableElementRef,
timelineStartAligned,
]);
return { handleMouseDown, handleMouseUp, handleMouseMove }; return { handleMouseDown, handleMouseUp, handleMouseMove };
} }
export default useDraggableHandler; export default useDraggableElement;

View File

@ -1,75 +0,0 @@
import { useCallback } from "react";
import { ReviewSegment } from "@/types/review";
export const useEventUtils = (
events: ReviewSegment[],
segmentDuration: number,
) => {
const isStartOfEvent = useCallback(
(time: number): boolean => {
return events.some((event) => {
const segmentStart = getSegmentStart(event.start_time);
return time >= segmentStart && time < segmentStart + segmentDuration;
});
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[events, segmentDuration],
);
const isEndOfEvent = useCallback(
(time: number): boolean => {
return events.some((event) => {
if (typeof event.end_time === "number") {
const segmentEnd = getSegmentEnd(event.end_time);
return time >= segmentEnd - segmentDuration && time < segmentEnd;
}
return false;
});
},
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[events, segmentDuration],
);
const getSegmentStart = useCallback(
(time: number): number => {
return Math.floor(time / segmentDuration) * segmentDuration;
},
[segmentDuration],
);
const getSegmentEnd = useCallback(
(time: number): number => {
return Math.ceil(time / segmentDuration) * segmentDuration;
},
[segmentDuration],
);
const alignEndDateToTimeline = useCallback(
(time: number): number => {
const remainder = time % segmentDuration;
const adjustment = remainder !== 0 ? segmentDuration - remainder : 0;
return time + adjustment;
},
[segmentDuration],
);
const alignStartDateToTimeline = useCallback(
(time: number): number => {
const remainder = time % segmentDuration;
const adjustment = remainder === 0 ? 0 : -remainder;
return time + adjustment;
},
[segmentDuration],
);
return {
isStartOfEvent,
isEndOfEvent,
getSegmentStart,
getSegmentEnd,
alignEndDateToTimeline,
alignStartDateToTimeline,
};
};

View File

@ -0,0 +1,26 @@
import { useCallback } from "react";
export const useTimelineUtils = (segmentDuration: number) => {
const alignEndDateToTimeline = useCallback(
(time: number): number => {
const remainder = time % segmentDuration;
const adjustment = remainder !== 0 ? segmentDuration - remainder : 0;
return time + adjustment;
},
[segmentDuration],
);
const alignStartDateToTimeline = useCallback(
(time: number): number => {
const remainder = time % segmentDuration;
const adjustment = remainder === 0 ? 0 : -remainder;
return time + adjustment;
},
[segmentDuration],
);
return {
alignEndDateToTimeline,
alignStartDateToTimeline,
};
};

View File

@ -26,9 +26,10 @@ import { Toaster } from "@/components/ui/sonner";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import axios from "axios"; import axios from "axios";
import { format } from "date-fns"; import { format } from "date-fns";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { DateRange } from "react-day-picker"; import { DateRange } from "react-day-picker";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { useLocation } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
@ -42,6 +43,8 @@ function Export() {
"exports/", "exports/",
(url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data), (url: string) => axios({ baseURL: baseUrl, url }).then((res) => res.data),
); );
const location = useLocation();
const [dialogOpen, setDialogOpen] = useState(false);
// Export States // Export States
const [camera, setCamera] = useState<string | undefined>(); const [camera, setCamera] = useState<string | undefined>();
@ -142,6 +145,23 @@ function Export() {
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
const Content = isDesktop ? DialogContent : DrawerContent; const Content = isDesktop ? DialogContent : DrawerContent;
useEffect(() => {
if (location.state && location.state.start && location.state.end) {
const startTimeString = format(
new Date(location.state.start * 1000),
"HH:mm:ss",
);
const endTimeString = format(
new Date(location.state.end * 1000),
"HH:mm:ss",
);
setStartTime(startTimeString);
setEndTime(endTimeString);
setDialogOpen(true);
}
}, [location.state]);
return ( return (
<div className="size-full p-2 overflow-hidden flex flex-col"> <div className="size-full p-2 overflow-hidden flex flex-col">
<Toaster /> <Toaster />
@ -167,7 +187,7 @@ function Export() {
</AlertDialog> </AlertDialog>
<div className="w-full h-14"> <div className="w-full h-14">
<Create> <Create open={dialogOpen} onOpenChange={setDialogOpen}>
<Trigger> <Trigger>
<Button variant="select">New Export</Button> <Button variant="select">New Export</Button>
</Trigger> </Trigger>

View File

@ -23,7 +23,9 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import HlsVideoPlayer from "@/components/player/HlsVideoPlayer"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useNavigate } from "react-router-dom";
// Color data // Color data
const colors = [ const colors = [
@ -129,6 +131,18 @@ function UIPlayground() {
Math.round((Date.now() / 1000 - 15 * 60) / 60) * 60, Math.round((Date.now() / 1000 - 15 * 60) / 60) * 60,
); );
const [exportStartTime, setExportStartTime] = useState(
Math.round((Date.now() / 1000 - 45 * 60) / 60) * 60,
);
const [exportEndTime, setExportEndTime] = useState(
Math.round((Date.now() / 1000 - 43 * 60) / 60) * 60,
);
const [showExportHandles, setShowExportHandles] = useState(false);
const navigate = useNavigate();
useMemo(() => { useMemo(() => {
const initialEvents = Array.from({ length: 50 }, generateRandomEvent); const initialEvents = Array.from({ length: 50 }, generateRandomEvent);
setMockEvents(initialEvents); setMockEvents(initialEvents);
@ -158,8 +172,6 @@ function UIPlayground() {
timestampSpread: 15, timestampSpread: 15,
}); });
const videoRef = useRef<HTMLVideoElement | null>(null);
const possibleZoomLevels = [ const possibleZoomLevels = [
{ segmentDuration: 60, timestampSpread: 15 }, { segmentDuration: 60, timestampSpread: 15 },
{ segmentDuration: 30, timestampSpread: 5 }, { segmentDuration: 30, timestampSpread: 5 },
@ -223,6 +235,26 @@ function UIPlayground() {
<p className="text-small"> <p className="text-small">
Handlebar is dragging: {isDragging ? "yes" : "no"} Handlebar is dragging: {isDragging ? "yes" : "no"}
</p> </p>
<p className="text-small">
Export start timestamp: {exportStartTime} -&nbsp;
{new Date(exportStartTime * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
month: "short",
day: "2-digit",
second: "2-digit",
})}
</p>
<p className="text-small">
Export end timestamp: {exportEndTime} -&nbsp;
{new Date(exportEndTime * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
month: "short",
day: "2-digit",
second: "2-digit",
})}
</p>
<div className="my-4"> <div className="my-4">
<Heading as="h4">Timeline type</Heading> <Heading as="h4">Timeline type</Heading>
<Select <Select
@ -245,17 +277,41 @@ function UIPlayground() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex p-2 justify-start items-center">
<Switch
id="exporthandles"
checked={showExportHandles}
onCheckedChange={() => {
setShowExportHandles(!showExportHandles);
if (showExportHandles) {
setExportEndTime(
Math.round((Date.now() / 1000 - 43 * 60) / 60) * 60,
);
setExportStartTime(
Math.round((Date.now() / 1000 - 45 * 60) / 60) * 60,
);
}
}}
/>
<Label className="ml-2" htmlFor="exporthandles">
Show Export Handles
</Label>
</div>
<div>
<Button
onClick={() => {
navigate("/export", {
state: { start: exportStartTime, end: exportEndTime },
});
}}
disabled={!showExportHandles}
>
Export
</Button>
</div>
<div className="w-[40px] my-4"> <div className="w-[40px] my-4">
<CameraActivityIndicator /> <CameraActivityIndicator />
</div> </div>
<div className="">
{birdseyeConfig && (
<BirdseyeLivePlayer
birdseyeConfig={birdseyeConfig}
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
/>
)}
</div>
<p> <p>
<Button onClick={handleZoomOut} disabled={zoomLevel === 0}> <Button onClick={handleZoomOut} disabled={zoomLevel === 0}>
Zoom Out Zoom Out
@ -267,6 +323,14 @@ function UIPlayground() {
Zoom In Zoom In
</Button> </Button>
</p> </p>
<div className="">
{birdseyeConfig && (
<BirdseyeLivePlayer
birdseyeConfig={birdseyeConfig}
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
/>
)}
</div>
<Heading as="h4" className="my-5"> <Heading as="h4" className="my-5">
Color scheme Color scheme
</Heading> </Heading>
@ -293,14 +357,6 @@ function UIPlayground() {
</div> </div>
</div> </div>
<div className="absolute left-96 top-96 bottom-96 right-96">
<HlsVideoPlayer
className="size-full"
videoRef={videoRef}
currentSource="http://localhost:5173/vod/side_cam/start/1710345600/end/1710349200/master.m3u8"
/>
</div>
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar"> <div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
{!isEventsReviewTimeline && ( {!isEventsReviewTimeline && (
<MotionReviewTimeline <MotionReviewTimeline
@ -315,6 +371,11 @@ function UIPlayground() {
showMinimap // show / hide the minimap showMinimap // show / hide the minimap
minimapStartTime={minimapStartTime} // start time of the minimap - the earlier time (eg 1:00pm) minimapStartTime={minimapStartTime} // start time of the minimap - the earlier time (eg 1:00pm)
minimapEndTime={minimapEndTime} // end of the minimap - the later time (eg 3:00pm) minimapEndTime={minimapEndTime} // end of the minimap - the later time (eg 3:00pm)
showExportHandles={showExportHandles}
exportStartTime={exportStartTime}
setExportStartTime={setExportStartTime}
exportEndTime={exportEndTime}
setExportEndTime={setExportEndTime}
events={mockEvents} // events, including new has_been_reviewed and severity properties events={mockEvents} // events, including new has_been_reviewed and severity properties
motion_events={mockMotionData} motion_events={mockMotionData}
severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right
@ -334,6 +395,11 @@ function UIPlayground() {
showMinimap // show / hide the minimap showMinimap // show / hide the minimap
minimapStartTime={minimapStartTime} // start time of the minimap - the earlier time (eg 1:00pm) minimapStartTime={minimapStartTime} // start time of the minimap - the earlier time (eg 1:00pm)
minimapEndTime={minimapEndTime} // end of the minimap - the later time (eg 3:00pm) minimapEndTime={minimapEndTime} // end of the minimap - the later time (eg 3:00pm)
showExportHandles={showExportHandles}
exportStartTime={exportStartTime}
setExportStartTime={setExportStartTime}
exportEndTime={exportEndTime}
setExportEndTime={setExportEndTime}
events={mockEvents} // events, including new has_been_reviewed and severity properties events={mockEvents} // events, including new has_been_reviewed and severity properties
severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later

View File

@ -0,0 +1 @@
export type DraggableElement = "handlebar" | "export_start" | "export_end";

View File

@ -6,7 +6,7 @@ import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useEventUtils } from "@/hooks/use-event-utils"; import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useScrollLockout } from "@/hooks/use-mouse-listener"; import { useScrollLockout } from "@/hooks/use-mouse-listener";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview"; import { Preview } from "@/types/preview";
@ -355,10 +355,7 @@ function DetectionReview({
// timeline interaction // timeline interaction
const { alignStartDateToTimeline } = useEventUtils( const { alignStartDateToTimeline } = useTimelineUtils(segmentDuration);
reviewItems?.all ?? [],
segmentDuration,
);
const scrollLock = useScrollLockout(contentRef); const scrollLock = useScrollLockout(contentRef);

View File

@ -61,7 +61,7 @@
--ring: 222.2 84% 4.9%; --ring: 222.2 84% 4.9%;
--selected: hsl(228, 89%, 63%); --selected: hsl(228, 89%, 63%);
--selected: 228, 89%, 63%; --selected: 228 89% 63%;
--radius: 0.5rem; --radius: 0.5rem;
@ -75,13 +75,13 @@
--severity_motion_dimmed: var(--yellow-200); --severity_motion_dimmed: var(--yellow-200);
--motion_review: hsl(44, 94%, 50%); --motion_review: hsl(44, 94%, 50%);
--motion_review: 44, 94%, 50%; --motion_review: 44 94% 50%;
--motion_review_dimmed: hsl(44, 60%, 40%); --motion_review_dimmed: hsl(44, 60%, 40%);
--motion_review_dimmed: 44, 60%, 40%; --motion_review_dimmed: 44 60% 40%;
--audio_review: hsl(228, 94%, 67%); --audio_review: hsl(228, 94%, 67%);
--audio_review: 228, 94%, 67%; --audio_review: 228 94% 67%;
} }
.dark { .dark {
@ -146,6 +146,6 @@
--ring: 212.7 26.8% 83.9%; --ring: 212.7 26.8% 83.9%;
--selected: hsl(228, 89%, 63%); --selected: hsl(228, 89%, 63%);
--selected: 228, 89%, 63%; --selected: 228 89% 63%;
} }
} }