mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-17 16:44:29 +03:00
zoom animations
This commit is contained in:
parent
63e21763ec
commit
e6e792cb21
@ -6,7 +6,11 @@ import React, {
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
import {
|
||||
ReviewSegment,
|
||||
ReviewSeverity,
|
||||
TimelineZoomDirection,
|
||||
} from "@/types/review";
|
||||
import ReviewTimeline from "./ReviewTimeline";
|
||||
import {
|
||||
VirtualizedEventSegments,
|
||||
@ -35,6 +39,8 @@ export type EventReviewTimelineProps = {
|
||||
timelineRef?: RefObject<HTMLDivElement>;
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
onHandlebarDraggingChange?: (isDragging: boolean) => void;
|
||||
isZooming: boolean;
|
||||
zoomDirection: TimelineZoomDirection;
|
||||
dense?: boolean;
|
||||
};
|
||||
|
||||
@ -60,6 +66,8 @@ export function EventReviewTimeline({
|
||||
timelineRef,
|
||||
contentRef,
|
||||
onHandlebarDraggingChange,
|
||||
isZooming,
|
||||
zoomDirection,
|
||||
dense = false,
|
||||
}: EventReviewTimelineProps) {
|
||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||
@ -128,19 +136,6 @@ export function EventReviewTimeline({
|
||||
[],
|
||||
);
|
||||
|
||||
// keep handlebar centered when zooming
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
scrollToSegment(
|
||||
alignStartDateToTimeline(handlebarTime ?? timelineStart),
|
||||
true,
|
||||
"auto",
|
||||
);
|
||||
}, 0);
|
||||
// we only want to scroll when zooming level changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [segmentDuration]);
|
||||
|
||||
return (
|
||||
<ReviewTimeline
|
||||
timelineRef={selectedTimelineRef}
|
||||
@ -160,6 +155,8 @@ export function EventReviewTimeline({
|
||||
dense={dense}
|
||||
segments={segmentTimes}
|
||||
scrollToSegment={scrollToSegment}
|
||||
isZooming={isZooming}
|
||||
zoomDirection={zoomDirection}
|
||||
>
|
||||
<VirtualizedEventSegments
|
||||
ref={virtualizedSegmentsRef}
|
||||
|
||||
@ -6,7 +6,11 @@ import React, {
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { MotionData, ReviewSegment } from "@/types/review";
|
||||
import {
|
||||
MotionData,
|
||||
ReviewSegment,
|
||||
TimelineZoomDirection,
|
||||
} from "@/types/review";
|
||||
import ReviewTimeline from "./ReviewTimeline";
|
||||
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
|
||||
import {
|
||||
@ -38,6 +42,8 @@ export type MotionReviewTimelineProps = {
|
||||
timelineRef?: RefObject<HTMLDivElement>;
|
||||
onHandlebarDraggingChange?: (isDragging: boolean) => void;
|
||||
dense?: boolean;
|
||||
isZooming: boolean;
|
||||
zoomDirection: TimelineZoomDirection;
|
||||
};
|
||||
|
||||
export function MotionReviewTimeline({
|
||||
@ -64,6 +70,8 @@ export function MotionReviewTimeline({
|
||||
timelineRef,
|
||||
onHandlebarDraggingChange,
|
||||
dense = false,
|
||||
isZooming,
|
||||
zoomDirection,
|
||||
}: MotionReviewTimelineProps) {
|
||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
||||
@ -172,6 +180,8 @@ export function MotionReviewTimeline({
|
||||
dense={dense}
|
||||
segments={segmentTimes}
|
||||
scrollToSegment={scrollToSegment}
|
||||
isZooming={isZooming}
|
||||
zoomDirection={zoomDirection}
|
||||
>
|
||||
<VirtualizedMotionSegments
|
||||
ref={virtualizedSegmentsRef}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import useDraggableElement from "@/hooks/use-draggable-element";
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DraggableElement } from "@/types/draggable-element";
|
||||
import { TimelineZoomDirection } from "@/types/review";
|
||||
import {
|
||||
ReactNode,
|
||||
RefObject,
|
||||
@ -32,6 +34,8 @@ export type ReviewTimelineProps = {
|
||||
dense: boolean;
|
||||
segments: number[];
|
||||
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
|
||||
isZooming: boolean;
|
||||
zoomDirection: TimelineZoomDirection;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@ -55,6 +59,8 @@ export function ReviewTimeline({
|
||||
dense,
|
||||
segments,
|
||||
scrollToSegment,
|
||||
isZooming,
|
||||
zoomDirection,
|
||||
children,
|
||||
}: ReviewTimelineProps) {
|
||||
const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false);
|
||||
@ -323,11 +329,14 @@ export function ReviewTimeline({
|
||||
return (
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className={`no-scrollbar relative h-full select-none overflow-y-auto bg-secondary ${
|
||||
className={cn(
|
||||
"no-scrollbar relative h-full select-none overflow-y-auto bg-secondary transition-all duration-500 ease-in-out",
|
||||
isZooming && zoomDirection === "in" && "animate-timeline-zoom-in",
|
||||
isZooming && zoomDirection === "out" && "animate-timeline-zoom-out",
|
||||
isDragging && (showHandlebar || showExportHandles)
|
||||
? "cursor-grabbing"
|
||||
: "cursor-auto"
|
||||
}`}
|
||||
: "cursor-auto",
|
||||
)}
|
||||
>
|
||||
<div ref={segmentsRef} className="relative flex flex-col">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>
|
||||
|
||||
@ -9,7 +9,7 @@ import React, {
|
||||
import { EventSegment } from "./EventSegment";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
|
||||
interface VirtualizedEventSegmentsProps {
|
||||
type VirtualizedEventSegmentsProps = {
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
segments: number[];
|
||||
events: ReviewSegment[];
|
||||
@ -23,7 +23,7 @@ interface VirtualizedEventSegmentsProps {
|
||||
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||
dense: boolean;
|
||||
alignStartDateToTimeline: (timestamp: number) => number;
|
||||
}
|
||||
};
|
||||
|
||||
export interface VirtualizedEventSegmentsRef {
|
||||
scrollToSegment: (
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { TimelineZoomDirection } from "@/types/review";
|
||||
|
||||
type ZoomSettings = {
|
||||
segmentDuration: number;
|
||||
@ -10,6 +11,8 @@ type UseTimelineZoomProps = {
|
||||
zoomLevels: ZoomSettings[];
|
||||
onZoomChange: (newZoomLevel: number) => void;
|
||||
pinchThresholdPercent?: number;
|
||||
timelineRef: React.RefObject<HTMLDivElement>;
|
||||
timelineDuration: number;
|
||||
};
|
||||
|
||||
export function useTimelineZoom({
|
||||
@ -17,6 +20,8 @@ export function useTimelineZoom({
|
||||
zoomLevels,
|
||||
onZoomChange,
|
||||
pinchThresholdPercent = 20,
|
||||
timelineRef,
|
||||
timelineDuration,
|
||||
}: UseTimelineZoomProps) {
|
||||
const [zoomLevel, setZoomLevel] = useState(
|
||||
zoomLevels.findIndex(
|
||||
@ -25,6 +30,9 @@ export function useTimelineZoom({
|
||||
level.timestampSpread === zoomSettings.timestampSpread,
|
||||
),
|
||||
);
|
||||
const [isZooming, setIsZooming] = useState(false);
|
||||
const [zoomDirection, setZoomDirection] =
|
||||
useState<TimelineZoomDirection>(null);
|
||||
const touchStartDistanceRef = useRef(0);
|
||||
|
||||
const getPinchThreshold = useCallback(() => {
|
||||
@ -37,18 +45,46 @@ export function useTimelineZoom({
|
||||
|
||||
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) {
|
||||
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],
|
||||
[zoomLevels, onZoomChange, timelineRef, timelineDuration],
|
||||
);
|
||||
|
||||
const debouncedZoom = useCallback(() => {
|
||||
@ -134,5 +170,5 @@ export function useTimelineZoom({
|
||||
};
|
||||
}, [handleWheel, handleTouchStart, handleTouchMove]);
|
||||
|
||||
return { zoomLevel, handleZoom };
|
||||
return { zoomLevel, handleZoom, isZooming, zoomDirection };
|
||||
}
|
||||
|
||||
@ -195,10 +195,12 @@ function UIPlayground() {
|
||||
[possibleZoomLevels],
|
||||
);
|
||||
|
||||
const { zoomLevel, handleZoom } = useTimelineZoom({
|
||||
const { zoomLevel, handleZoom, isZooming, zoomDirection } = useTimelineZoom({
|
||||
zoomSettings,
|
||||
zoomLevels: possibleZoomLevels,
|
||||
onZoomChange: handleZoomChange,
|
||||
timelineRef: reviewTimelineRef,
|
||||
timelineDuration: 4 * 60 * 60,
|
||||
});
|
||||
|
||||
const handleZoomIn = () => handleZoom(-1);
|
||||
@ -407,6 +409,8 @@ function UIPlayground() {
|
||||
motion_events={mockMotionData}
|
||||
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
|
||||
dense={isMobile} // dense will produce a smaller handlebar and only minute resolution on timestamps
|
||||
isZooming={isZooming} // is the timeline actively zooming?
|
||||
zoomDirection={zoomDirection} // is the timeline zooming in or out
|
||||
/>
|
||||
)}
|
||||
{isEventsReviewTimeline && (
|
||||
@ -432,6 +436,8 @@ function UIPlayground() {
|
||||
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
|
||||
timelineRef={reviewTimelineRef} // save a ref to this timeline to connect with the summary timeline
|
||||
dense // dense will produce a smaller handlebar and only minute resolution on timestamps
|
||||
isZooming={isZooming} // is the timeline actively zooming?
|
||||
zoomDirection={zoomDirection} // is the timeline zooming in or out
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -67,3 +67,5 @@ export type ConsolidatedSegmentData = {
|
||||
severity: ReviewSeverity | "empty";
|
||||
reviewed: boolean;
|
||||
};
|
||||
|
||||
export type TimelineZoomDirection = "in" | "out" | null;
|
||||
|
||||
@ -486,6 +486,11 @@ function DetectionReview({
|
||||
|
||||
// timeline interaction
|
||||
|
||||
const timelineDuration = useMemo(
|
||||
() => timeRange.before - timeRange.after,
|
||||
[timeRange],
|
||||
);
|
||||
|
||||
const [zoomSettings, setZoomSettings] = useState({
|
||||
segmentDuration: 60,
|
||||
timestampSpread: 15,
|
||||
@ -507,17 +512,14 @@ function DetectionReview({
|
||||
[possibleZoomLevels],
|
||||
);
|
||||
|
||||
useTimelineZoom({
|
||||
const { isZooming, zoomDirection } = useTimelineZoom({
|
||||
zoomSettings,
|
||||
zoomLevels: possibleZoomLevels,
|
||||
onZoomChange: handleZoomChange,
|
||||
timelineRef: reviewTimelineRef,
|
||||
timelineDuration,
|
||||
});
|
||||
|
||||
const timelineDuration = useMemo(
|
||||
() => timeRange.before - timeRange.after,
|
||||
[timeRange],
|
||||
);
|
||||
|
||||
const { alignStartDateToTimeline, getVisibleTimelineDuration } =
|
||||
useTimelineUtils({
|
||||
segmentDuration: zoomSettings.segmentDuration,
|
||||
@ -795,6 +797,8 @@ function DetectionReview({
|
||||
contentRef={contentRef}
|
||||
timelineRef={reviewTimelineRef}
|
||||
dense={isMobile}
|
||||
isZooming={isZooming}
|
||||
zoomDirection={zoomDirection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -1130,6 +1134,8 @@ function MotionReview({
|
||||
setScrubbing(scrubbing);
|
||||
}}
|
||||
dense={isMobileOnly}
|
||||
isZooming={false}
|
||||
zoomDirection={null}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="size-full" />
|
||||
|
||||
@ -632,6 +632,7 @@ export function RecordingView({
|
||||
|
||||
type TimelineProps = {
|
||||
contentRef: MutableRefObject<HTMLDivElement | null>;
|
||||
timelineRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
mainCamera: string;
|
||||
timelineType: TimelineType;
|
||||
timeRange: TimeRange;
|
||||
@ -645,6 +646,7 @@ type TimelineProps = {
|
||||
};
|
||||
function Timeline({
|
||||
contentRef,
|
||||
timelineRef,
|
||||
mainCamera,
|
||||
timelineType,
|
||||
timeRange,
|
||||
@ -656,6 +658,9 @@ function Timeline({
|
||||
setScrubbing,
|
||||
setExportRange,
|
||||
}: TimelineProps) {
|
||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
||||
|
||||
// timeline interaction
|
||||
|
||||
const [zoomSettings, setZoomSettings] = useState({
|
||||
@ -679,10 +684,12 @@ function Timeline({
|
||||
[possibleZoomLevels],
|
||||
);
|
||||
|
||||
useTimelineZoom({
|
||||
const { isZooming, zoomDirection } = useTimelineZoom({
|
||||
zoomSettings,
|
||||
zoomLevels: possibleZoomLevels,
|
||||
onZoomChange: handleZoomChange,
|
||||
timelineRef: selectedTimelineRef,
|
||||
timelineDuration: timeRange.after - timeRange.before,
|
||||
});
|
||||
|
||||
// motion data
|
||||
@ -727,6 +734,7 @@ function Timeline({
|
||||
{timelineType == "timeline" ? (
|
||||
!isLoading ? (
|
||||
<MotionReviewTimeline
|
||||
timelineRef={selectedTimelineRef}
|
||||
segmentDuration={zoomSettings.segmentDuration}
|
||||
timestampSpread={zoomSettings.timestampSpread}
|
||||
timelineStart={timeRange.before}
|
||||
@ -743,6 +751,8 @@ function Timeline({
|
||||
motion_events={motionData ?? []}
|
||||
contentRef={contentRef}
|
||||
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
||||
isZooming={isZooming}
|
||||
zoomDirection={zoomDirection}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="size-full" />
|
||||
|
||||
@ -32,6 +32,8 @@ module.exports = {
|
||||
scale2: "scale2 3s ease-in-out infinite",
|
||||
scale3: "scale3 3s ease-in-out infinite",
|
||||
scale4: "scale4 3s ease-in-out infinite",
|
||||
"timeline-zoom-in": "timeline-zoom-in 0.3s ease-out",
|
||||
"timeline-zoom-out": "timeline-zoom-out 0.3s ease-out",
|
||||
},
|
||||
aspectRatio: {
|
||||
wide: "32 / 9",
|
||||
@ -140,6 +142,16 @@ module.exports = {
|
||||
"40%, 60%": { transform: "scale(1.4)" },
|
||||
"30%, 70%": { transform: "scale(1)" },
|
||||
},
|
||||
"timeline-zoom-in": {
|
||||
"0%": { transform: "translateY(0)", opacity: "1" },
|
||||
"50%": { transform: "translateY(0%)", opacity: "0.5" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
"timeline-zoom-out": {
|
||||
"0%": { transform: "translateY(0)", opacity: "1" },
|
||||
"50%": { transform: "translateY(0%)", opacity: "0.5" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
},
|
||||
},
|
||||
screens: {
|
||||
xs: "480px",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user