frigate/web/src/components/timeline/VirtualizedMotionSegments.tsx

258 lines
7.5 KiB
TypeScript
Raw Normal View History

import React, {
useCallback,
useEffect,
useRef,
useState,
forwardRef,
useImperativeHandle,
} from "react";
import MotionSegment from "./MotionSegment";
import { ReviewSegment, MotionData } from "@/types/review";
type VirtualizedMotionSegmentsProps = {
timelineRef: React.RefObject<HTMLDivElement>;
segments: number[];
events: ReviewSegment[];
motion_events: MotionData[];
segmentDuration: number;
timestampSpread: number;
showMinimap: boolean;
minimapStartTime?: number;
minimapEndTime?: number;
contentRef: React.RefObject<HTMLDivElement>;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
dense: boolean;
motionOnly: boolean;
getMotionSegmentValue: (timestamp: number) => number;
getRecordingAvailability: (timestamp: number) => boolean | undefined;
};
export interface VirtualizedMotionSegmentsRef {
scrollToSegment: (
segmentTime: number,
ifNeeded?: boolean,
behavior?: ScrollBehavior,
) => void;
}
const SEGMENT_HEIGHT = 8;
const OVERSCAN_COUNT = 20;
export const VirtualizedMotionSegments = forwardRef<
VirtualizedMotionSegmentsRef,
VirtualizedMotionSegmentsProps
>(
(
{
timelineRef,
segments,
events,
segmentDuration,
timestampSpread,
showMinimap,
minimapStartTime,
minimapEndTime,
setHandlebarTime,
dense,
motionOnly,
getMotionSegmentValue,
getRecordingAvailability,
},
ref,
) => {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const updateVisibleRange = useCallback(() => {
if (timelineRef.current) {
const { scrollTop, clientHeight } = timelineRef.current;
const start = Math.max(
0,
Math.floor(scrollTop / SEGMENT_HEIGHT) - OVERSCAN_COUNT,
);
const end = Math.min(
segments.length,
Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) +
OVERSCAN_COUNT,
);
setVisibleRange({ start, end });
}
}, [segments.length, timelineRef]);
useEffect(() => {
const container = timelineRef.current;
if (container) {
const handleScroll = () => {
window.requestAnimationFrame(updateVisibleRange);
};
container.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", updateVisibleRange);
updateVisibleRange();
return () => {
container.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", updateVisibleRange);
};
}
}, [updateVisibleRange, timelineRef]);
const scrollToSegment = useCallback(
(
segmentTime: number,
ifNeeded: boolean = true,
behavior: ScrollBehavior = "smooth",
) => {
const segmentIndex = segments.findIndex((time) => time === segmentTime);
if (
segmentIndex !== -1 &&
containerRef.current &&
timelineRef.current
) {
const timelineHeight = timelineRef.current.clientHeight;
const targetScrollTop = segmentIndex * SEGMENT_HEIGHT;
const centeredScrollTop =
targetScrollTop - timelineHeight / 2 + SEGMENT_HEIGHT / 2;
const isVisible =
segmentIndex > visibleRange.start + OVERSCAN_COUNT &&
segmentIndex < visibleRange.end - OVERSCAN_COUNT;
if (!ifNeeded || !isVisible) {
timelineRef.current.scrollTo({
top: Math.max(0, centeredScrollTop),
behavior: behavior,
});
}
updateVisibleRange();
}
},
[segments, timelineRef, visibleRange, updateVisibleRange],
);
useImperativeHandle(ref, () => ({
scrollToSegment,
}));
const renderSegment = useCallback(
(segmentTime: number, index: number) => {
const motionStart = segmentTime;
const motionEnd = motionStart + segmentDuration;
const firstHalfMotionValue = getMotionSegmentValue(motionStart);
const secondHalfMotionValue = getMotionSegmentValue(
motionStart + segmentDuration / 2,
);
const segmentMotion =
firstHalfMotionValue > 0 || secondHalfMotionValue > 0;
const overlappingReviewItems = events.some(
(item) =>
(item.start_time >= motionStart && item.start_time < motionEnd) ||
((item.end_time ?? segmentTime) > motionStart &&
(item.end_time ?? segmentTime) <= motionEnd) ||
(item.start_time <= motionStart &&
(item.end_time ?? segmentTime) >= motionEnd),
);
const hasRecording = getRecordingAvailability(segmentTime);
if ((!segmentMotion || overlappingReviewItems) && motionOnly) {
return null; // Skip rendering this segment in motion only mode
}
return (
<div
key={`${segmentTime}_${segmentDuration}`}
style={{
position: "absolute",
top: `${(visibleRange.start + index) * SEGMENT_HEIGHT}px`,
height: `${SEGMENT_HEIGHT}px`,
width: "100%",
}}
>
<MotionSegment
events={events}
firstHalfMotionValue={firstHalfMotionValue}
secondHalfMotionValue={secondHalfMotionValue}
hasRecording={hasRecording}
segmentDuration={segmentDuration}
segmentTime={segmentTime}
timestampSpread={timestampSpread}
motionOnly={motionOnly}
showMinimap={showMinimap}
minimapStartTime={minimapStartTime}
minimapEndTime={minimapEndTime}
setHandlebarTime={setHandlebarTime}
scrollToSegment={scrollToSegment}
dense={dense}
/>
</div>
);
},
[
events,
getMotionSegmentValue,
getRecordingAvailability,
motionOnly,
segmentDuration,
showMinimap,
minimapStartTime,
minimapEndTime,
setHandlebarTime,
scrollToSegment,
dense,
timestampSpread,
visibleRange.start,
],
);
const totalHeight = segments.length * SEGMENT_HEIGHT;
const visibleSegments = segments.slice(
visibleRange.start,
visibleRange.end,
);
return (
<div
ref={containerRef}
className="h-full w-full"
style={{ position: "relative", willChange: "transform" }}
>
<div style={{ height: `${totalHeight}px`, position: "relative" }}>
{visibleRange.start > 0 && (
<div
style={{
position: "absolute",
top: 0,
height: `${visibleRange.start * SEGMENT_HEIGHT}px`,
width: "100%",
}}
aria-hidden="true"
/>
)}
{visibleSegments.map((segmentTime, index) =>
renderSegment(segmentTime, index),
)}
{visibleRange.end < segments.length && (
<div
style={{
position: "absolute",
top: `${visibleRange.end * SEGMENT_HEIGHT}px`,
height: `${
(segments.length - visibleRange.end) * SEGMENT_HEIGHT
}px`,
width: "100%",
}}
aria-hidden="true"
/>
)}
</div>
</div>
);
},
);
export default VirtualizedMotionSegments;