forked from Github/frigate
feat: Timeline UI (#2830)
This commit is contained in:
4
web/src/components/Timeline/ScrollPermission.ts
Normal file
4
web/src/components/Timeline/ScrollPermission.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ScrollPermission {
|
||||
allowed: boolean;
|
||||
resetAfterSeeked: boolean;
|
||||
}
|
||||
242
web/src/components/Timeline/Timeline.tsx
Normal file
242
web/src/components/Timeline/Timeline.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Fragment, h } from 'preact';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { getTimelineEventBlocksFromTimelineEvents } from '../../utils/Timeline/timelineEventUtils';
|
||||
import { ScrollPermission } from './ScrollPermission';
|
||||
import { TimelineBlocks } from './TimelineBlocks';
|
||||
import { TimelineChangeEvent } from './TimelineChangeEvent';
|
||||
import { DisabledControls, TimelineControls } from './TimelineControls';
|
||||
import { TimelineEvent } from './TimelineEvent';
|
||||
import { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineProps {
|
||||
events: TimelineEvent[];
|
||||
isPlaying: boolean;
|
||||
onChange: (event: TimelineChangeEvent) => void;
|
||||
onPlayPause?: (isPlaying: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Timeline({ events, isPlaying, onChange, onPlayPause }: TimelineProps) {
|
||||
const timelineContainerRef = useRef<HTMLDivElement>(undefined);
|
||||
|
||||
const [timeline, setTimeline] = useState<TimelineEventBlock[]>([]);
|
||||
const [disabledControls, setDisabledControls] = useState<DisabledControls>({
|
||||
playPause: false,
|
||||
next: true,
|
||||
previous: false,
|
||||
});
|
||||
const [timelineOffset, setTimelineOffset] = useState<number | undefined>(undefined);
|
||||
const [markerTime, setMarkerTime] = useState<Date | undefined>(undefined);
|
||||
const [currentEvent, setCurrentEvent] = useState<TimelineEventBlock | undefined>(undefined);
|
||||
const [scrollTimeout, setScrollTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
|
||||
const [scrollPermission, setScrollPermission] = useState<ScrollPermission>({
|
||||
allowed: true,
|
||||
resetAfterSeeked: false,
|
||||
});
|
||||
|
||||
const scrollToPosition = useCallback(
|
||||
(positionX: number) => {
|
||||
if (timelineContainerRef.current) {
|
||||
const permission: ScrollPermission = {
|
||||
allowed: true,
|
||||
resetAfterSeeked: true,
|
||||
};
|
||||
setScrollPermission(permission);
|
||||
timelineContainerRef.current.scroll({
|
||||
left: positionX,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
},
|
||||
[timelineContainerRef]
|
||||
);
|
||||
|
||||
const scrollToEvent = useCallback(
|
||||
(event, offset = 0) => {
|
||||
scrollToPosition(event.positionX + offset - timelineOffset);
|
||||
},
|
||||
[timelineOffset, scrollToPosition]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeline.length > 0 && currentEvent) {
|
||||
const currentIndex = currentEvent.index;
|
||||
if (currentIndex === 0) {
|
||||
setDisabledControls((previous) => ({
|
||||
...previous,
|
||||
next: false,
|
||||
previous: true,
|
||||
}));
|
||||
} else if (currentIndex === timeline.length - 1) {
|
||||
setDisabledControls((previous) => ({
|
||||
...previous,
|
||||
previous: false,
|
||||
next: true,
|
||||
}));
|
||||
} else {
|
||||
setDisabledControls((previous) => ({
|
||||
...previous,
|
||||
previous: false,
|
||||
next: false,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [timeline, currentEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && timelineOffset) {
|
||||
const timelineEvents = getTimelineEventBlocksFromTimelineEvents(events, timelineOffset);
|
||||
const lastEventIndex = timelineEvents.length - 1;
|
||||
const recentEvent = timelineEvents[lastEventIndex];
|
||||
|
||||
setTimeline(timelineEvents);
|
||||
setMarkerTime(recentEvent.startTime);
|
||||
setCurrentEvent(recentEvent);
|
||||
scrollToEvent(recentEvent);
|
||||
}
|
||||
}, [events, timelineOffset, scrollToEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
const timelineIsLoaded = timeline.length > 0;
|
||||
if (timelineIsLoaded) {
|
||||
const lastEvent = timeline[timeline.length - 1];
|
||||
scrollToEvent(lastEvent);
|
||||
}
|
||||
}, [timeline, scrollToEvent]);
|
||||
|
||||
const checkMarkerForEvent = (markerTime: Date) => {
|
||||
const adjustedMarkerTime = new Date(markerTime);
|
||||
adjustedMarkerTime.setSeconds(markerTime.getSeconds() + 1);
|
||||
|
||||
return [...timeline]
|
||||
.reverse()
|
||||
.find(
|
||||
(timelineEvent) =>
|
||||
timelineEvent.startTime.getTime() <= adjustedMarkerTime.getTime() &&
|
||||
timelineEvent.endTime.getTime() >= adjustedMarkerTime.getTime()
|
||||
);
|
||||
};
|
||||
|
||||
const seekCompleteHandler = (markerTime: Date) => {
|
||||
if (scrollPermission.allowed) {
|
||||
const markerEvent = checkMarkerForEvent(markerTime);
|
||||
setCurrentEvent(markerEvent);
|
||||
|
||||
onChange({
|
||||
markerTime,
|
||||
timelineEvent: markerEvent,
|
||||
seekComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (scrollPermission.resetAfterSeeked) {
|
||||
setScrollPermission({
|
||||
allowed: true,
|
||||
resetAfterSeeked: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const waitForSeekComplete = (markerTime: Date) => {
|
||||
clearTimeout(scrollTimeout);
|
||||
setScrollTimeout(setTimeout(() => seekCompleteHandler(markerTime), 150));
|
||||
};
|
||||
|
||||
const onTimelineScrollHandler = () => {
|
||||
if (timelineContainerRef.current && timeline.length > 0) {
|
||||
const currentMarkerTime = getCurrentMarkerTime();
|
||||
setMarkerTime(currentMarkerTime);
|
||||
waitForSeekComplete(currentMarkerTime);
|
||||
onChange({
|
||||
timelineEvent: currentEvent,
|
||||
markerTime: currentMarkerTime,
|
||||
seekComplete: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentMarkerTime = useCallback(() => {
|
||||
if (timelineContainerRef.current && timeline.length > 0) {
|
||||
const scrollPosition = timelineContainerRef.current.scrollLeft;
|
||||
const firstTimelineEvent = timeline[0] as TimelineEventBlock;
|
||||
const firstTimelineEventStartTime = firstTimelineEvent.startTime.getTime();
|
||||
return new Date(firstTimelineEventStartTime + scrollPosition * 1000);
|
||||
}
|
||||
}, [timeline, timelineContainerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timelineContainerRef) {
|
||||
const timelineContainerWidth = timelineContainerRef.current.offsetWidth;
|
||||
const offset = Math.round(timelineContainerWidth / 2);
|
||||
setTimelineOffset(offset);
|
||||
}
|
||||
}, [timelineContainerRef]);
|
||||
|
||||
const handleViewEvent = useCallback(
|
||||
(event: TimelineEventBlock) => {
|
||||
scrollToEvent(event);
|
||||
setMarkerTime(getCurrentMarkerTime());
|
||||
},
|
||||
[scrollToEvent, getCurrentMarkerTime]
|
||||
);
|
||||
|
||||
const onPlayPauseHandler = (isPlaying: boolean) => {
|
||||
onPlayPause(isPlaying);
|
||||
};
|
||||
|
||||
const onPreviousHandler = () => {
|
||||
if (currentEvent) {
|
||||
const previousEvent = timeline[currentEvent.index - 1];
|
||||
setCurrentEvent(previousEvent);
|
||||
scrollToEvent(previousEvent);
|
||||
}
|
||||
};
|
||||
const onNextHandler = () => {
|
||||
if (currentEvent) {
|
||||
const nextEvent = timeline[currentEvent.index + 1];
|
||||
setCurrentEvent(nextEvent);
|
||||
scrollToEvent(nextEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const timelineBlocks = useMemo(() => {
|
||||
if (timelineOffset && timeline.length > 0) {
|
||||
return <TimelineBlocks timeline={timeline} firstBlockOffset={timelineOffset} onEventClick={handleViewEvent} />;
|
||||
}
|
||||
}, [timeline, timelineOffset, handleViewEvent]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className='flex-grow-1'>
|
||||
<div className='w-full text-center'>
|
||||
<span className='text-black dark:text-white'>
|
||||
{markerTime && <span>{markerTime.toLocaleTimeString()}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<div className='absolute left-0 top-0 h-full w-full text-center'>
|
||||
<div className='h-full text-center' style={{ margin: '0 auto' }}>
|
||||
<div
|
||||
className='z-20 h-full absolute'
|
||||
style={{
|
||||
left: 'calc(100% / 2)',
|
||||
borderRight: '2px solid rgba(252, 211, 77)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={timelineContainerRef} onScroll={onTimelineScrollHandler} className='overflow-x-auto hide-scroll'>
|
||||
{timelineBlocks}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TimelineControls
|
||||
disabled={disabledControls}
|
||||
isPlaying={isPlaying}
|
||||
onPrevious={onPreviousHandler}
|
||||
onPlayPause={onPlayPauseHandler}
|
||||
onNext={onNextHandler}
|
||||
className='mt-2'
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
25
web/src/components/Timeline/TimelineBlockView.tsx
Normal file
25
web/src/components/Timeline/TimelineBlockView.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
import { getColorFromTimelineEvent } from '../../utils/tailwind/twTimelineEventUtil';
|
||||
import { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineBlockViewProps {
|
||||
block: TimelineEventBlock;
|
||||
onClick: (block: TimelineEventBlock) => void;
|
||||
}
|
||||
|
||||
export const TimelineBlockView = ({ block, onClick }: TimelineBlockViewProps) => {
|
||||
const onClickHandler = useCallback(() => onClick(block), [block, onClick]);
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
onClick={onClickHandler}
|
||||
className={`absolute z-10 rounded-full ${getColorFromTimelineEvent(block)} h-2`}
|
||||
style={{
|
||||
top: `${block.yOffset}px`,
|
||||
left: `${block.positionX}px`,
|
||||
width: `${block.width}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
47
web/src/components/Timeline/TimelineBlocks.tsx
Normal file
47
web/src/components/Timeline/TimelineBlocks.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { h } from 'preact';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { findLargestYOffsetInBlocks, getTimelineWidthFromBlocks } from '../../utils/Timeline/timelineEventUtils';
|
||||
import { convertRemToPixels } from '../../utils/windowUtils';
|
||||
import { TimelineBlockView } from './TimelineBlockView';
|
||||
import { TimelineEventBlock } from './TimelineEventBlock';
|
||||
|
||||
interface TimelineBlocksProps {
|
||||
timeline: TimelineEventBlock[];
|
||||
firstBlockOffset: number;
|
||||
onEventClick: (block: TimelineEventBlock) => void;
|
||||
}
|
||||
|
||||
export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: TimelineBlocksProps) => {
|
||||
const timelineEventBlocks = useMemo(() => {
|
||||
if (timeline.length > 0 && firstBlockOffset) {
|
||||
const largestYOffsetInBlocks = findLargestYOffsetInBlocks(timeline);
|
||||
const timelineContainerHeight = largestYOffsetInBlocks + convertRemToPixels(1);
|
||||
const timelineContainerWidth = getTimelineWidthFromBlocks(timeline, firstBlockOffset);
|
||||
const timelineBlockOffset = (timelineContainerHeight - largestYOffsetInBlocks) / 2;
|
||||
return (
|
||||
<div
|
||||
className='relative'
|
||||
style={{
|
||||
height: `${timelineContainerHeight}px`,
|
||||
width: `${timelineContainerWidth}px`,
|
||||
background: "url('/marker.png')",
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: '30px',
|
||||
backgroundRepeat: 'repeat',
|
||||
}}
|
||||
>
|
||||
{timeline.map((block) => {
|
||||
const onClickHandler = (block: TimelineEventBlock) => onEventClick(block);
|
||||
const updatedBlock: TimelineEventBlock = {
|
||||
...block,
|
||||
yOffset: block.yOffset + timelineBlockOffset,
|
||||
};
|
||||
return <TimelineBlockView block={updatedBlock} onClick={onClickHandler} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [timeline, onEventClick, firstBlockOffset]);
|
||||
|
||||
return timelineEventBlocks;
|
||||
};
|
||||
7
web/src/components/Timeline/TimelineChangeEvent.ts
Normal file
7
web/src/components/Timeline/TimelineChangeEvent.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineChangeEvent {
|
||||
timelineEvent: TimelineEvent;
|
||||
markerTime: Date;
|
||||
seekComplete: boolean;
|
||||
}
|
||||
45
web/src/components/Timeline/TimelineControls.tsx
Normal file
45
web/src/components/Timeline/TimelineControls.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { h } from 'preact';
|
||||
import Next from '../../icons/Next';
|
||||
import Pause from '../../icons/Pause';
|
||||
import Play from '../../icons/Play';
|
||||
import Previous from '../../icons/Previous';
|
||||
import { BubbleButton } from '../BubbleButton';
|
||||
|
||||
export interface DisabledControls {
|
||||
playPause: boolean;
|
||||
next: boolean;
|
||||
previous: boolean;
|
||||
}
|
||||
|
||||
interface TimelineControlsProps {
|
||||
disabled: DisabledControls;
|
||||
className?: string;
|
||||
isPlaying: boolean;
|
||||
onPlayPause: (isPlaying: boolean) => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
}
|
||||
|
||||
export const TimelineControls = ({
|
||||
disabled,
|
||||
isPlaying,
|
||||
onPlayPause,
|
||||
onNext,
|
||||
onPrevious,
|
||||
className = '',
|
||||
}: TimelineControlsProps) => {
|
||||
const onPlayClickHandler = () => {
|
||||
onPlayPause(!isPlaying);
|
||||
};
|
||||
return (
|
||||
<div className={`flex space-x-2 self-center ${className}`}>
|
||||
<BubbleButton variant='secondary' onClick={onPrevious} disabled={disabled.previous}>
|
||||
<Previous />
|
||||
</BubbleButton>
|
||||
<BubbleButton onClick={onPlayClickHandler}>{!isPlaying ? <Play /> : <Pause />}</BubbleButton>
|
||||
<BubbleButton variant='secondary' onClick={onNext} disabled={disabled.next}>
|
||||
<Next />
|
||||
</BubbleButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
8
web/src/components/Timeline/TimelineEvent.ts
Normal file
8
web/src/components/Timeline/TimelineEvent.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface TimelineEvent {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
id: string;
|
||||
label: 'car' | 'person' | 'dog';
|
||||
}
|
||||
9
web/src/components/Timeline/TimelineEventBlock.ts
Normal file
9
web/src/components/Timeline/TimelineEventBlock.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { TimelineEvent } from './TimelineEvent';
|
||||
|
||||
export interface TimelineEventBlock extends TimelineEvent {
|
||||
index: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
positionX: number;
|
||||
seconds: number;
|
||||
}
|
||||
Reference in New Issue
Block a user