Adapt review timeline for mobile devices (#10120)

* adapt timeline to mobile

* remove unused

* tweaks

* pointer cursor on segments
This commit is contained in:
Josh Hawkins 2024-02-28 07:18:08 -06:00 committed by GitHub
parent 5edaaceaf2
commit 485057abc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 183 additions and 106 deletions

32
web/package-lock.json generated
View File

@ -15,6 +15,7 @@
"@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "^1.1.3",
@ -1392,6 +1393,37 @@
} }
} }
}, },
"node_modules/@radix-ui/react-hover-card": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.0.7.tgz",
"integrity": "sha512-OcUN2FU0YpmajD/qkph3XzMcK/NmSk9hGWnjV68p6QiZMgILugusgQwnLSDs3oFSJYGKf3Y49zgFedhGh04k9A==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "1.0.1",
"@radix-ui/react-compose-refs": "1.0.1",
"@radix-ui/react-context": "1.0.1",
"@radix-ui/react-dismissable-layer": "1.0.5",
"@radix-ui/react-popper": "1.1.3",
"@radix-ui/react-portal": "1.0.4",
"@radix-ui/react-presence": "1.0.1",
"@radix-ui/react-primitive": "1.0.3",
"@radix-ui/react-use-controllable-state": "1.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": { "node_modules/@radix-ui/react-id": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz",

View File

@ -20,6 +20,7 @@
"@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-context-menu": "^2.1.5",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "^1.1.3",

View File

@ -127,8 +127,8 @@ export default function LivePlayer({
<div <div
className={`relative flex justify-center w-full outline ${ className={`relative flex justify-center w-full outline ${
activeTracking activeTracking
? "outline-severity_alert outline-1 rounded-2xl shadow-[0_0_6px_1px] shadow-severity_alert" ? "outline-severity_alert outline-1 rounded-2xl shadow-[0_0_6px_2px] shadow-severity_alert"
: "outline-0" : "outline-0 outline-background"
} transition-all duration-500 ${className}`} } transition-all duration-500 ${className}`}
> >
<div className="absolute top-0 inset-x-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none"></div> <div className="absolute top-0 inset-x-0 rounded-2xl z-10 w-full h-[30%] bg-gradient-to-b from-black/20 to-transparent pointer-events-none"></div>

View File

@ -10,7 +10,6 @@ import {
import EventSegment from "./EventSegment"; import EventSegment from "./EventSegment";
import { useEventUtils } from "@/hooks/use-event-utils"; import { useEventUtils } from "@/hooks/use-event-utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewSegment, ReviewSeverity } from "@/types/review";
import { TooltipProvider } from "../ui/tooltip";
export type EventReviewTimelineProps = { export type EventReviewTimelineProps = {
segmentDuration: number; segmentDuration: number;
@ -56,14 +55,18 @@ export function EventReviewTimeline({
[timelineEnd, timelineStart] [timelineEnd, timelineStart]
); );
const { alignDateToTimeline } = useEventUtils(events, segmentDuration); const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils(
events,
segmentDuration
);
const { handleMouseDown, handleMouseUp, handleMouseMove } = const { handleMouseDown, handleMouseUp, handleMouseMove } =
useDraggableHandler({ useDraggableHandler({
contentRef, contentRef,
timelineRef, timelineRef,
scrollTimeRef, scrollTimeRef,
alignDateToTimeline, alignStartDateToTimeline,
alignEndDateToTimeline,
segmentDuration, segmentDuration,
showHandlebar, showHandlebar,
timelineDuration, timelineDuration,
@ -96,7 +99,7 @@ export function EventReviewTimeline({
// Generate segments for the timeline // Generate segments for the timeline
const generateSegments = useCallback(() => { const generateSegments = useCallback(() => {
const segmentCount = timelineDuration / segmentDuration; const segmentCount = timelineDuration / segmentDuration;
const segmentAlignedTime = alignDateToTimeline(timelineStart); const segmentAlignedTime = alignStartDateToTimeline(timelineStart);
return Array.from({ length: segmentCount }, (_, index) => { return Array.from({ length: segmentCount }, (_, index) => {
const segmentTime = segmentAlignedTime - index * segmentDuration; const segmentTime = segmentAlignedTime - index * segmentDuration;
@ -172,7 +175,7 @@ export function EventReviewTimeline({
timelineHeight / (timelineDuration / segmentDuration); timelineHeight / (timelineDuration / segmentDuration);
// Calculate the segment index corresponding to the target time // Calculate the segment index corresponding to the target time
const alignedHandlebarTime = alignDateToTimeline(handlebarTime); const alignedHandlebarTime = alignStartDateToTimeline(handlebarTime);
const segmentIndex = Math.ceil( const segmentIndex = Math.ceil(
(timelineStart - alignedHandlebarTime) / segmentDuration (timelineStart - alignedHandlebarTime) / segmentDuration
); );
@ -213,19 +216,15 @@ export function EventReviewTimeline({
]); ]);
return ( return (
<TooltipProvider skipDelayDuration={3000}>
<div <div
ref={timelineRef} ref={timelineRef}
className={`relative w-[120px] md:w-[100px] h-full overflow-y-scroll no-scrollbar bg-secondary ${ className={`relative h-full overflow-y-scroll no-scrollbar bg-secondary ${
isDragging && showHandlebar ? "cursor-grabbing" : "cursor-auto" isDragging && showHandlebar ? "cursor-grabbing" : "cursor-auto"
}`} }`}
> >
<div className="flex flex-col">{segments}</div> <div className="flex flex-col">{segments}</div>
{showHandlebar && ( {showHandlebar && (
<div <div className={`absolute left-0 top-0 z-20 w-full `} role="scrollbar">
className={`absolute left-0 top-0 z-20 w-full `}
role="scrollbar"
>
<div className={`flex items-center justify-center `}> <div className={`flex items-center justify-center `}>
<div <div
ref={scrollTimeRef} ref={scrollTimeRef}
@ -250,7 +249,6 @@ export function EventReviewTimeline({
</div> </div>
)} )}
</div> </div>
</TooltipProvider>
); );
} }

View File

@ -9,8 +9,13 @@ import React, {
useMemo, useMemo,
useRef, useRef,
} from "react"; } from "react";
import { Tooltip, TooltipContent } from "../ui/tooltip"; import { isDesktop } from "react-device-detect";
import { TooltipTrigger } from "@radix-ui/react-tooltip"; import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "../ui/hover-card";
import { HoverCardPortal } from "@radix-ui/react-hover-card";
type EventSegmentProps = { type EventSegmentProps = {
events: ReviewSegment[]; events: ReviewSegment[];
@ -33,8 +38,6 @@ type MinimapSegmentProps = {
}; };
type TickSegmentProps = { type TickSegmentProps = {
isFirstSegmentInMinimap: boolean;
isLastSegmentInMinimap: boolean;
timestamp: Date; timestamp: Date;
timestampSpread: number; timestampSpread: number;
}; };
@ -58,25 +61,23 @@ function MinimapBounds({
<> <>
{isFirstSegmentInMinimap && ( {isFirstSegmentInMinimap && (
<div <div
className="absolute inset-0 -bottom-5 w-full flex items-center justify-center text-xs text-primary font-medium z-20 text-center text-[8px] scroll-mt-8" className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary font-medium z-20 text-center text-[10px] scroll-mt-8"
ref={firstMinimapSegmentRef} ref={firstMinimapSegmentRef}
> >
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], { {new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
month: "short", ...(isDesktop && { month: "short", day: "2-digit" }),
day: "2-digit",
})} })}
</div> </div>
)} )}
{isLastSegmentInMinimap && ( {isLastSegmentInMinimap && (
<div className="absolute inset-0 -top-1 w-full flex items-center justify-center text-xs text-primary font-medium z-20 text-center text-[8px]"> <div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary font-medium z-20 text-center text-[10px]">
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], { {new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
month: "short", ...(isDesktop && { month: "short", day: "2-digit" }),
day: "2-digit",
})} })}
</div> </div>
)} )}
@ -84,15 +85,10 @@ function MinimapBounds({
); );
} }
function Tick({ function Tick({ timestamp, timestampSpread }: TickSegmentProps) {
isFirstSegmentInMinimap,
isLastSegmentInMinimap,
timestamp,
timestampSpread,
}: TickSegmentProps) {
return ( return (
<div className="w-[12px] h-2 flex justify-left items-end"> <div className="absolute">
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( <div className="flex items-end content-end w-[12px] h-2">
<div <div
className={`h-0.5 ${ className={`h-0.5 ${
timestamp.getMinutes() % timestampSpread === 0 && timestamp.getMinutes() % timestampSpread === 0 &&
@ -101,7 +97,7 @@ function Tick({
: "w-[8px] bg-gray-600" : "w-[8px] bg-gray-600"
}`} }`}
></div> ></div>
)} </div>
</div> </div>
); );
} }
@ -114,7 +110,7 @@ function Timestamp({
segmentKey, segmentKey,
}: TimestampSegmentProps) { }: TimestampSegmentProps) {
return ( return (
<div className="w-[36px] pl-[3px] leading-[9px] h-2 flex justify-left items-top z-10"> <div className="absolute left-[15px] top-[1px] h-2 z-10">
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
<div <div
key={`${segmentKey}_timestamp`} key={`${segmentKey}_timestamp`}
@ -152,7 +148,10 @@ export function EventSegment({
getEventThumbnail, getEventThumbnail,
} = useSegmentUtils(segmentDuration, events, severityType); } = useSegmentUtils(segmentDuration, events, severityType);
const { alignDateToTimeline } = useEventUtils(events, segmentDuration); const { alignStartDateToTimeline, alignEndDateToTimeline } = useEventUtils(
events,
segmentDuration
);
const severity = useMemo( const severity = useMemo(
() => getSeverity(segmentTime, displaySeverityType), () => getSeverity(segmentTime, displaySeverityType),
@ -177,7 +176,7 @@ export function EventSegment({
const startTimestamp = useMemo(() => { const startTimestamp = useMemo(() => {
const eventStart = getEventStart(segmentTime); const eventStart = getEventStart(segmentTime);
if (eventStart) { if (eventStart) {
return alignDateToTimeline(eventStart); return alignStartDateToTimeline(eventStart);
} }
}, [getEventStart, segmentTime]); }, [getEventStart, segmentTime]);
@ -191,23 +190,26 @@ export function EventSegment({
const segmentKey = useMemo(() => segmentTime, [segmentTime]); const segmentKey = useMemo(() => segmentTime, [segmentTime]);
const alignedMinimapStartTime = useMemo( const alignedMinimapStartTime = useMemo(
() => alignDateToTimeline(minimapStartTime ?? 0), () => alignStartDateToTimeline(minimapStartTime ?? 0),
[minimapStartTime, alignDateToTimeline] [minimapStartTime, alignStartDateToTimeline]
); );
const alignedMinimapEndTime = useMemo( const alignedMinimapEndTime = useMemo(
() => alignDateToTimeline(minimapEndTime ?? 0), () => alignEndDateToTimeline(minimapEndTime ?? 0),
[minimapEndTime, alignDateToTimeline] [minimapEndTime, alignEndDateToTimeline]
); );
const isInMinimapRange = useMemo(() => { const isInMinimapRange = useMemo(() => {
return ( return (
showMinimap && showMinimap &&
minimapStartTime && segmentTime >= alignedMinimapStartTime &&
minimapEndTime && segmentTime < alignedMinimapEndTime
segmentTime > minimapStartTime &&
segmentTime < minimapEndTime
); );
}, [showMinimap, minimapStartTime, minimapEndTime, segmentTime]); }, [
showMinimap,
alignedMinimapStartTime,
alignedMinimapEndTime,
segmentTime,
]);
const isFirstSegmentInMinimap = useMemo(() => { const isFirstSegmentInMinimap = useMemo(() => {
return showMinimap && segmentTime === alignedMinimapStartTime; return showMinimap && segmentTime === alignedMinimapStartTime;
@ -236,11 +238,17 @@ export function EventSegment({
} }
}, [showMinimap, isFirstSegmentInMinimap, events, segmentDuration]); }, [showMinimap, isFirstSegmentInMinimap, events, segmentDuration]);
const segmentClasses = `flex flex-row ${ const segmentClasses = `h-2 relative w-[55px] md:w-[100px] ${
showMinimap ? (isInMinimapRange ? "bg-muted" : "bg-background") : "" showMinimap
? isInMinimapRange
? "bg-card"
: isLastSegmentInMinimap
? ""
: "opacity-70"
: ""
} ${ } ${
isFirstSegmentInMinimap || isLastSegmentInMinimap isFirstSegmentInMinimap || isLastSegmentInMinimap
? "relative h-2 border-b border-gray-500" ? "relative h-2 border-b-2 border-gray-500"
: "" : ""
}`; }`;
@ -280,7 +288,11 @@ export function EventSegment({
}, [startTimestamp]); }, [startTimestamp]);
return ( return (
<div key={segmentKey} className={segmentClasses}> <div
key={segmentKey}
className={segmentClasses}
data-segment-time={new Date(segmentTime * 1000)}
>
<MinimapBounds <MinimapBounds
isFirstSegmentInMinimap={isFirstSegmentInMinimap} isFirstSegmentInMinimap={isFirstSegmentInMinimap}
isLastSegmentInMinimap={isLastSegmentInMinimap} isLastSegmentInMinimap={isLastSegmentInMinimap}
@ -289,12 +301,7 @@ export function EventSegment({
firstMinimapSegmentRef={firstMinimapSegmentRef} firstMinimapSegmentRef={firstMinimapSegmentRef}
/> />
<Tick <Tick timestamp={timestamp} timestampSpread={timestampSpread} />
isFirstSegmentInMinimap={isFirstSegmentInMinimap}
isLastSegmentInMinimap={isLastSegmentInMinimap}
timestamp={timestamp}
timestampSpread={timestampSpread}
/>
<Timestamp <Timestamp
isFirstSegmentInMinimap={isFirstSegmentInMinimap} isFirstSegmentInMinimap={isFirstSegmentInMinimap}
@ -307,35 +314,35 @@ export function EventSegment({
{severity.map((severityValue, index) => ( {severity.map((severityValue, index) => (
<React.Fragment key={index}> <React.Fragment key={index}>
{severityValue === displaySeverityType && ( {severityValue === displaySeverityType && (
<Tooltip delayDuration={300}> <HoverCard openDelay={200} closeDelay={100}>
<div <div
className="mr-3 w-[8px] h-2 flex justify-left items-end" className="absolute left-1/2 transform -translate-x-1/2 w-[8px] h-2 ml-[2px] z-10 cursor-pointer"
data-severity={severityValue} data-severity={severityValue}
> >
<TooltipTrigger asChild> <HoverCardTrigger asChild>
<div <div
key={`${segmentKey}_${index}_primary_data`} key={`${segmentKey}_${index}_primary_data`}
className={` 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]}`}
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}
></div> ></div>
</TooltipTrigger> </HoverCardTrigger>
<TooltipContent className="rounded-2xl" side="left"> <HoverCardPortal>
<HoverCardContent
className="rounded-2xl w-[250px] p-2"
side="left"
>
<img <img
className="rounded-lg" className="rounded-lg"
src={`${apiHost}${eventThumbnail.replace("/media/frigate/", "")}`} src={`${apiHost}${eventThumbnail.replace("/media/frigate/", "")}`}
/> />
</TooltipContent> </HoverCardContent>
</HoverCardPortal>
</div> </div>
</Tooltip> </HoverCard>
)} )}
{severityValue !== displaySeverityType && ( {severityValue !== displaySeverityType && (
<div className="h-2 flex flex-grow justify-end items-end"> <div className="absolute right-0 h-2 z-10">
<div <div
key={`${segmentKey}_${index}_secondary_data`} key={`${segmentKey}_${index}_secondary_data`}
className={` className={`

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -42,7 +42,7 @@ export const useEventUtils = (
[segmentDuration] [segmentDuration]
); );
const alignDateToTimeline = useCallback( const alignEndDateToTimeline = useCallback(
(time: number): number => { (time: number): number => {
const remainder = time % segmentDuration; const remainder = time % segmentDuration;
const adjustment = remainder !== 0 ? segmentDuration - remainder : 0; const adjustment = remainder !== 0 ? segmentDuration - remainder : 0;
@ -51,11 +51,21 @@ export const useEventUtils = (
[segmentDuration] [segmentDuration]
); );
const alignStartDateToTimeline = useCallback(
(time: number): number => {
const remainder = time % segmentDuration;
const adjustment = remainder === 0 ? 0 : -(remainder);
return time + adjustment;
},
[segmentDuration]
);
return { return {
isStartOfEvent, isStartOfEvent,
isEndOfEvent, isEndOfEvent,
getSegmentStart, getSegmentStart,
getSegmentEnd, getSegmentEnd,
alignDateToTimeline, alignEndDateToTimeline,
alignStartDateToTimeline,
}; };
}; };

View File

@ -4,7 +4,8 @@ interface DragHandlerProps {
contentRef: React.RefObject<HTMLElement>; contentRef: React.RefObject<HTMLElement>;
timelineRef: React.RefObject<HTMLDivElement>; timelineRef: React.RefObject<HTMLDivElement>;
scrollTimeRef: React.RefObject<HTMLDivElement>; scrollTimeRef: React.RefObject<HTMLDivElement>;
alignDateToTimeline: (time: number) => number; alignStartDateToTimeline: (time: number) => number;
alignEndDateToTimeline: (time: number) => number;
segmentDuration: number; segmentDuration: number;
showHandlebar: boolean; showHandlebar: boolean;
timelineDuration: number; timelineDuration: number;
@ -20,7 +21,7 @@ function useDraggableHandler({
contentRef, contentRef,
timelineRef, timelineRef,
scrollTimeRef, scrollTimeRef,
alignDateToTimeline, alignStartDateToTimeline,
segmentDuration, segmentDuration,
showHandlebar, showHandlebar,
timelineDuration, timelineDuration,
@ -94,7 +95,7 @@ function useDraggableHandler({
); );
const segmentIndex = Math.floor(newHandlePosition / segmentHeight); const segmentIndex = Math.floor(newHandlePosition / segmentHeight);
const segmentStartTime = alignDateToTimeline( const segmentStartTime = alignStartDateToTimeline(
timelineStart - segmentIndex * segmentDuration timelineStart - segmentIndex * segmentDuration
); );

View File

@ -82,7 +82,7 @@ export default function EventView({
}; };
}, [reviewPages]); }, [reviewPages]);
const { alignDateToTimeline } = useEventUtils( const { alignStartDateToTimeline } = useEventUtils(
reviewItems.all, reviewItems.all,
segmentDuration segmentDuration
); );
@ -270,7 +270,8 @@ export default function EventView({
ref={lastRow ? lastReviewRef : minimapRef} ref={lastRow ? lastReviewRef : minimapRef}
data-start={value.start_time} data-start={value.start_time}
data-segment-start={ data-segment-start={
alignDateToTimeline(value.start_time) - segmentDuration alignStartDateToTimeline(value.start_time) -
segmentDuration
} }
className="outline outline-offset-1 outline-0 rounded-lg shadow-none transition-all duration-500 my-1 md:my-0" className="outline outline-offset-1 outline-0 rounded-lg shadow-none transition-all duration-500 my-1 md:my-0"
> >
@ -291,7 +292,7 @@ export default function EventView({
)} )}
</div> </div>
</div> </div>
<div className="w-[44px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar"> <div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
<EventReviewTimeline <EventReviewTimeline
segmentDuration={segmentDuration} segmentDuration={segmentDuration}
timestampSpread={15} timestampSpread={15}