Timeline improvements (#16429)

* virtualize event segments

* use virtual segments in event review timeline

* add segmentkey to props

* virtualize motion segments

* use virtual segments in motion review timeline

* update draggable element hook to use only math

* timeline zooming hook

* add zooming to event review timeline

* update playground

* zoomable timeline on recording view

* consolidate divs in summary timeline

* only calculate motion data for visible motion segments

* use swr loading state

* fix motion only

* keep handlebar centered when zooming

* zoom animations

* clean up

* ensure motion only checks both halves of segment

* prevent handlebar jump when using motion only mode
This commit is contained in:
Josh Hawkins
2025-02-09 15:13:32 -06:00
committed by GitHub
parent 1f89844c67
commit cc2dbdcb44
17 changed files with 1157 additions and 515 deletions

View File

@@ -1,12 +1,4 @@
import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTimelineUtils } from "./use-timeline-utils";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
@@ -33,7 +25,8 @@ type DraggableElementProps = {
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
setDraggableElementPosition?: React.Dispatch<React.SetStateAction<number>>;
dense: boolean;
timelineSegments: ReactNode[];
segments: number[];
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
};
function useDraggableElement({
@@ -57,7 +50,8 @@ function useDraggableElement({
setIsDragging,
setDraggableElementPosition,
dense,
timelineSegments,
segments,
scrollToSegment,
}: DraggableElementProps) {
const { data: config } = useSWR<FrigateConfig>("config");
@@ -66,7 +60,6 @@ function useDraggableElement({
const [elementScrollIntoView, setElementScrollIntoView] = useState(true);
const [scrollEdgeSize, setScrollEdgeSize] = useState<number>();
const [fullTimelineHeight, setFullTimelineHeight] = useState<number>();
const [segments, setSegments] = useState<HTMLDivElement[]>([]);
const { alignStartDateToTimeline, getCumulativeScrollTop, segmentHeight } =
useTimelineUtils({
segmentDuration: segmentDuration,
@@ -201,11 +194,7 @@ function useDraggableElement({
draggableElementTimeRef.current.textContent =
getFormattedTimestamp(segmentStartTime);
if (scrollTimeline && !userInteracting) {
scrollIntoView(thumb, {
block: "center",
behavior: "smooth",
scrollMode: "if-needed",
});
scrollToSegment(segmentStartTime);
}
}
});
@@ -222,6 +211,7 @@ function useDraggableElement({
setDraggableElementPosition,
getFormattedTimestamp,
userInteracting,
scrollToSegment,
],
);
@@ -241,12 +231,6 @@ function useDraggableElement({
[contentRef, draggableElementRef, timelineRef, getClientYPosition],
);
useEffect(() => {
if (timelineRef.current && timelineSegments.length) {
setSegments(Array.from(timelineRef.current.querySelectorAll(".segment")));
}
}, [timelineRef, timelineCollapsed, timelineSegments]);
useEffect(() => {
let animationFrameId: number | null = null;
@@ -256,7 +240,7 @@ function useDraggableElement({
showDraggableElement &&
isDragging &&
clientYPosition &&
segments &&
segments.length > 0 &&
fullTimelineHeight
) {
const { scrollTop: scrolled } = timelineRef.current;
@@ -295,31 +279,18 @@ function useDraggableElement({
return;
}
let targetSegmentId = 0;
let offset = 0;
const start = Math.max(0, Math.floor(scrolled / segmentHeight));
segments.forEach((segmentElement: HTMLDivElement) => {
const rect = segmentElement.getBoundingClientRect();
const segmentTop =
rect.top + scrolled - timelineTopAbsolute - segmentHeight;
const segmentBottom =
rect.bottom + scrolled - timelineTopAbsolute - segmentHeight;
const relativePosition = newElementPosition - scrolled;
const segmentIndex =
Math.floor(relativePosition / segmentHeight) + start + 1;
// Check if handlebar position falls within the segment bounds
if (
newElementPosition >= segmentTop &&
newElementPosition <= segmentBottom
) {
targetSegmentId = parseFloat(
segmentElement.getAttribute("data-segment-id") || "0",
);
offset = Math.min(
segmentBottom - newElementPosition,
segmentHeight,
);
return;
}
});
const targetSegmentTime = segments[segmentIndex];
if (targetSegmentTime === undefined) return;
const segmentStart = segmentIndex * segmentHeight - scrolled;
const offset = Math.min(segmentStart - relativePosition, segmentHeight);
if ((draggingAtTopEdge || draggingAtBottomEdge) && scrollEdgeSize) {
if (draggingAtTopEdge) {
@@ -349,8 +320,8 @@ function useDraggableElement({
}
const setTime = alignSetTimeToSegment
? targetSegmentId
: targetSegmentId + segmentDuration * (offset / segmentHeight);
? targetSegmentTime
: targetSegmentTime + segmentDuration * (offset / segmentHeight);
updateDraggableElementPosition(
newElementPosition,
@@ -361,7 +332,7 @@ function useDraggableElement({
if (setDraggableElementTime) {
setDraggableElementTime(
targetSegmentId + segmentDuration * (offset / segmentHeight),
targetSegmentTime + segmentDuration * (offset / segmentHeight),
);
}
@@ -397,6 +368,7 @@ function useDraggableElement({
draggingAtTopEdge,
draggingAtBottomEdge,
showDraggableElement,
segments,
]);
useEffect(() => {
@@ -408,24 +380,23 @@ function useDraggableElement({
!isDragging &&
segments.length > 0
) {
const { scrollTop: scrolled } = timelineRef.current;
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
if (!userInteracting) {
scrollToSegment(alignedSegmentTime);
}
const segmentElement = timelineRef.current.querySelector(
`[data-segment-id="${alignedSegmentTime}"]`,
const segmentIndex = segments.findIndex(
(time) => time === alignedSegmentTime,
);
if (segmentElement) {
const timelineRect = timelineRef.current.getBoundingClientRect();
const timelineTopAbsolute = timelineRect.top;
const rect = segmentElement.getBoundingClientRect();
const segmentTop = rect.top + scrolled - timelineTopAbsolute;
if (segmentIndex >= 0) {
const segmentStart = segmentIndex * segmentHeight;
const offset =
((draggableElementTime - alignedSegmentTime) / segmentDuration) *
segmentHeight;
// subtract half the height of the handlebar cross bar (4px) for pixel perfection
const newElementPosition = segmentTop - offset - 2;
const newElementPosition = segmentStart - offset - 2;
updateDraggableElementPosition(
newElementPosition,
@@ -454,14 +425,27 @@ function useDraggableElement({
segments,
]);
const findNextAvailableSegment = useCallback(
(startTime: number) => {
let searchTime = startTime;
while (searchTime < timelineStartAligned + timelineDuration) {
if (segments.includes(searchTime)) {
return searchTime;
}
searchTime += segmentDuration;
}
return null;
},
[segments, timelineStartAligned, timelineDuration, segmentDuration],
);
useEffect(() => {
if (
timelineRef.current &&
segmentsRef.current &&
draggableElementTime &&
timelineCollapsed &&
timelineSegments &&
segments
segments.length > 0
) {
setFullTimelineHeight(
Math.min(
@@ -469,47 +453,27 @@ function useDraggableElement({
segmentsRef.current.scrollHeight,
),
);
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
let segmentElement = timelineRef.current.querySelector(
`[data-segment-id="${alignedSegmentTime}"]`,
);
if (!segmentElement) {
if (segments.includes(alignedSegmentTime)) {
scrollToSegment(alignedSegmentTime);
} else {
// segment not found, maybe we collapsed over a collapsible segment
let searchTime = alignedSegmentTime;
const nextAvailableSegment =
findNextAvailableSegment(alignedSegmentTime);
while (
searchTime < timelineStartAligned &&
searchTime < timelineStartAligned + timelineDuration
) {
searchTime += segmentDuration;
segmentElement = timelineRef.current.querySelector(
`[data-segment-id="${searchTime}"]`,
);
if (segmentElement) {
// found, set time
if (setDraggableElementTime) {
setDraggableElementTime(searchTime);
}
return;
}
}
}
if (!segmentElement) {
// segment still not found, just start at the beginning of the timeline or at now()
if (segments?.length) {
const searchTime = parseInt(
segments[0].getAttribute("data-segment-id") || "0",
10,
);
if (nextAvailableSegment !== null) {
scrollToSegment(nextAvailableSegment);
if (setDraggableElementTime) {
setDraggableElementTime(searchTime);
setDraggableElementTime(nextAvailableSegment);
}
} else {
// segment still not found, just start at the beginning of the timeline or at now()
const firstAvailableSegment = segments[0] || timelineStartAligned;
scrollToSegment(firstAvailableSegment);
if (setDraggableElementTime) {
setDraggableElementTime(timelineStartAligned);
setDraggableElementTime(firstAvailableSegment);
}
}
}

View File

@@ -0,0 +1,174 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { TimelineZoomDirection } from "@/types/review";
type ZoomSettings = {
segmentDuration: number;
timestampSpread: number;
};
type UseTimelineZoomProps = {
zoomSettings: ZoomSettings;
zoomLevels: ZoomSettings[];
onZoomChange: (newZoomLevel: number) => void;
pinchThresholdPercent?: number;
timelineRef: React.RefObject<HTMLDivElement>;
timelineDuration: number;
};
export function useTimelineZoom({
zoomSettings,
zoomLevels,
onZoomChange,
pinchThresholdPercent = 20,
timelineRef,
timelineDuration,
}: UseTimelineZoomProps) {
const [zoomLevel, setZoomLevel] = useState(
zoomLevels.findIndex(
(level) =>
level.segmentDuration === zoomSettings.segmentDuration &&
level.timestampSpread === zoomSettings.timestampSpread,
),
);
const [isZooming, setIsZooming] = useState(false);
const [zoomDirection, setZoomDirection] =
useState<TimelineZoomDirection>(null);
const touchStartDistanceRef = useRef(0);
const getPinchThreshold = useCallback(() => {
return (window.innerHeight * pinchThresholdPercent) / 100;
}, [pinchThresholdPercent]);
const wheelDeltaRef = useRef(0);
const isZoomingRef = useRef(false);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleZoom = useCallback(
(delta: number) => {
setIsZooming(true);
setZoomDirection(delta > 0 ? "out" : "in");
setZoomLevel((prevLevel) => {
const newLevel = Math.max(
0,
Math.min(zoomLevels.length - 1, prevLevel - delta),
);
if (newLevel !== prevLevel && timelineRef.current) {
const { scrollTop, clientHeight, scrollHeight } = timelineRef.current;
// get time at the center of the viewable timeline
const centerRatio = (scrollTop + clientHeight / 2) / scrollHeight;
const centerTime = centerRatio * timelineDuration;
// calc the new total height based on the new zoom level
const newTotalHeight =
(timelineDuration / zoomLevels[newLevel].segmentDuration) * 8;
// calc the new scroll position to keep the center time in view
const newScrollTop =
(centerTime / timelineDuration) * newTotalHeight - clientHeight / 2;
onZoomChange(newLevel);
// Apply new scroll position after a short delay to allow for DOM update
setTimeout(() => {
if (timelineRef.current) {
timelineRef.current.scrollTop = newScrollTop;
}
}, 0);
}
return newLevel;
});
setTimeout(() => {
setIsZooming(false);
setZoomDirection(null);
}, 500);
},
[zoomLevels, onZoomChange, timelineRef, timelineDuration],
);
const debouncedZoom = useCallback(() => {
if (Math.abs(wheelDeltaRef.current) >= 200) {
handleZoom(wheelDeltaRef.current > 0 ? 1 : -1);
wheelDeltaRef.current = 0;
isZoomingRef.current = false;
} else {
isZoomingRef.current = false;
}
}, [handleZoom]);
const handleWheel = useCallback(
(event: WheelEvent) => {
if (event.ctrlKey) {
event.preventDefault();
if (!isZoomingRef.current) {
wheelDeltaRef.current += event.deltaY;
if (Math.abs(wheelDeltaRef.current) >= 200) {
isZoomingRef.current = true;
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
debouncedZoom();
}, 200);
}
}
}
},
[debouncedZoom],
);
const handleTouchStart = useCallback((event: TouchEvent) => {
if (event.touches.length === 2) {
event.preventDefault();
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const distance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY,
);
touchStartDistanceRef.current = distance;
}
}, []);
const handleTouchMove = useCallback(
(event: TouchEvent) => {
if (event.touches.length === 2) {
event.preventDefault();
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const currentDistance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY,
);
const distanceDelta = currentDistance - touchStartDistanceRef.current;
const pinchThreshold = getPinchThreshold();
if (Math.abs(distanceDelta) > pinchThreshold) {
handleZoom(distanceDelta > 0 ? -1 : 1);
touchStartDistanceRef.current = currentDistance;
}
}
},
[handleZoom, getPinchThreshold],
);
useEffect(() => {
window.addEventListener("wheel", handleWheel, { passive: false });
window.addEventListener("touchstart", handleTouchStart, { passive: false });
window.addEventListener("touchmove", handleTouchMove, { passive: false });
return () => {
window.removeEventListener("wheel", handleWheel);
window.removeEventListener("touchstart", handleTouchStart);
window.removeEventListener("touchmove", handleTouchMove);
};
}, [handleWheel, handleTouchStart, handleTouchMove]);
return { zoomLevel, handleZoom, isZooming, zoomDirection };
}