mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-07 02:18:07 +01:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
174
web/src/hooks/use-timeline-zoom.ts
Normal file
174
web/src/hooks/use-timeline-zoom.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user