mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-02 09:15:22 +03:00
refactor: pulled components apart and improved logic
This commit is contained in:
parent
21f9e9831b
commit
920c41d4c9
47
web/src/components/BubbleButton.tsx
Normal file
47
web/src/components/BubbleButton.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
interface BubbleButtonProps {
|
||||
variant?: 'primary' | 'secondary';
|
||||
children?: JSX.Element;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const BubbleButton = ({
|
||||
variant = 'primary',
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: BubbleButtonProps) => {
|
||||
const BASE_CLASS = 'rounded-full px-4 py-2';
|
||||
const PRIMARY_CLASS = 'text-white bg-blue-500 dark:text-black dark:bg-white';
|
||||
const SECONDARY_CLASS = 'text-black dark:text-white bg-transparent';
|
||||
let computedClass = BASE_CLASS;
|
||||
|
||||
if (disabled) {
|
||||
computedClass += ' text-gray-200 dark:text-gray-200';
|
||||
} else {
|
||||
if (variant === 'primary') {
|
||||
computedClass += ` ${PRIMARY_CLASS}`;
|
||||
} else if (variant === 'secondary') {
|
||||
computedClass += ` ${SECONDARY_CLASS}`;
|
||||
}
|
||||
}
|
||||
|
||||
const onClickHandler = () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button onClick={onClickHandler} className={`${computedClass} ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@ -1,174 +0,0 @@
|
||||
import { Fragment, h } from 'preact';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useApiHost, useEvents } from '../api';
|
||||
import { useSearchString } from '../hooks/useSearchString';
|
||||
import { Next } from '../icons/Next';
|
||||
import { Play } from '../icons/Play';
|
||||
import { Previous } from '../icons/Previous';
|
||||
import { HistoryHeader } from '../routes/HistoryHeader';
|
||||
import { longToDate } from '../utils/dateUtil';
|
||||
import Timeline from './Timeline/Timeline';
|
||||
|
||||
const getLast24Hours = () => {
|
||||
return new Number(new Date(new Date().getTime() - 24 * 60 * 60 * 1000)) / 1000;
|
||||
};
|
||||
|
||||
export default function HistoryViewer({ camera }) {
|
||||
const apiHost = useApiHost();
|
||||
const videoRef = useRef();
|
||||
const { searchString } = useSearchString(500, `camera=${camera}&after=${getLast24Hours()}`);
|
||||
const { data: events } = useEvents(searchString);
|
||||
const [timelineEvents, setTimelineEvents] = useState();
|
||||
|
||||
const [hasPlayed, setHasPlayed] = useState();
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentEvent, setCurrentEvent] = useState();
|
||||
const [currentEventIndex, setCurrentEventIndex] = useState();
|
||||
const [timelineOffset, setTimelineOffset] = useState(0);
|
||||
const [minHeight, setMinHeight] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
if (events) {
|
||||
const filteredEvents = [...events].reverse().filter((e) => e.end_time !== undefined);
|
||||
setTimelineEvents(filteredEvents);
|
||||
setCurrentEventIndex(filteredEvents.length - 1);
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const videoContainer = videoRef.current;
|
||||
if (videoContainer.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = Math.round(videoRef.current.currentTime);
|
||||
const offset = Math.round(timestamp);
|
||||
const triggerStateChange = offset !== timelineOffset;
|
||||
if (triggerStateChange) {
|
||||
setTimelineOffset(offset);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimelineChange = (timelineChangedEvent) => {
|
||||
if (timelineChangedEvent.seekComplete) {
|
||||
setCurrentEvent(timelineChangedEvent.event);
|
||||
}
|
||||
|
||||
const videoContainer = videoRef.current;
|
||||
if (videoContainer) {
|
||||
if (!videoContainer.paused) {
|
||||
videoContainer.pause();
|
||||
setHasPlayed(true);
|
||||
}
|
||||
|
||||
const videoHasPermissionToPlay = hasPlayed !== undefined;
|
||||
if (videoHasPermissionToPlay && timelineChangedEvent.seekComplete) {
|
||||
const markerTime = Math.abs(timelineChangedEvent.time - timelineChangedEvent.event.startTime) / 1000;
|
||||
videoContainer.currentTime = markerTime;
|
||||
if (hasPlayed) {
|
||||
videoContainer.play();
|
||||
setHasPlayed(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = function () {
|
||||
const videoContainer = videoRef.current;
|
||||
if (videoContainer) {
|
||||
if (videoContainer.paused) {
|
||||
videoContainer.play();
|
||||
} else {
|
||||
videoContainer.pause();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayed = () => {
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const handlePaused = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const handlePrevious = function () {
|
||||
setCurrentEventIndex(currentEvent.index - 1);
|
||||
};
|
||||
|
||||
const handleNext = function () {
|
||||
setCurrentEventIndex(currentEvent.index + 1);
|
||||
};
|
||||
|
||||
const handleMetadataLoad = () => {
|
||||
const videoContainer = videoRef.current;
|
||||
if (videoContainer) {
|
||||
setMinHeight(videoContainer.clientHeight);
|
||||
}
|
||||
};
|
||||
|
||||
const RenderVideo = useCallback(() => {
|
||||
if (currentEvent) {
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onPause={handlePaused}
|
||||
onPlay={handlePlayed}
|
||||
poster={`${apiHost}/api/events/${currentEvent.id}/snapshot.jpg`}
|
||||
onLoadedMetadata={handleMetadataLoad}
|
||||
preload='metadata'
|
||||
style={
|
||||
minHeight
|
||||
? {
|
||||
minHeight: `${minHeight}px`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
playsInline
|
||||
>
|
||||
<source type='application/vnd.apple.mpegurl' src={`${apiHost}/vod/event/${currentEvent.id}/index.m3u8`} />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
}, [currentEvent, apiHost, camera, videoRef]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{currentEvent && (
|
||||
<Fragment>
|
||||
<div className='relative flex flex-col'>
|
||||
<HistoryHeader
|
||||
camera={camera}
|
||||
date={currentEvent.startTime}
|
||||
end={currentEvent.endTime}
|
||||
objectLabel={currentEvent.label}
|
||||
className='mb-2'
|
||||
/>
|
||||
<RenderVideo />
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<Timeline
|
||||
events={timelineEvents}
|
||||
offset={timelineOffset}
|
||||
currentIndex={currentEventIndex}
|
||||
disableMarkerEvents={isPlaying}
|
||||
onChange={handleTimelineChange}
|
||||
/>
|
||||
|
||||
<div className='flex self-center'>
|
||||
<button onClick={handlePrevious}>
|
||||
<Previous />
|
||||
</button>
|
||||
<button onClick={handlePlay}>
|
||||
<Play />
|
||||
</button>
|
||||
<button onClick={handleNext}>
|
||||
<Next />
|
||||
</button>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
30
web/src/components/HistoryViewer/HistoryHeader.tsx
Normal file
30
web/src/components/HistoryViewer/HistoryHeader.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from '../Heading';
|
||||
import { TimelineEvent } from '../Timeline/Timeline';
|
||||
|
||||
interface HistoryHeaderProps {
|
||||
event: TimelineEvent;
|
||||
className?: string;
|
||||
}
|
||||
export const HistoryHeader = ({ event, className = '' }: HistoryHeaderProps) => {
|
||||
let title = 'No Event Found';
|
||||
let subtitle = <span>Event was not found at marker position.</span>;
|
||||
if (event) {
|
||||
const { startTime, endTime, label } = event;
|
||||
const thisMorning = new Date();
|
||||
thisMorning.setHours(0, 0, 0);
|
||||
const isToday = endTime.getTime() > thisMorning.getTime();
|
||||
title = label;
|
||||
subtitle = (
|
||||
<span>
|
||||
{isToday ? 'Today' : 'Yesterday'}, {startTime.toLocaleTimeString()} - {endTime.toLocaleTimeString()} ·
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`text-center ${className}`}>
|
||||
<Heading size='lg'>{title}</Heading>
|
||||
<div>{subtitle}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
137
web/src/components/HistoryViewer/HistoryVideo.tsx
Normal file
137
web/src/components/HistoryViewer/HistoryVideo.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useApiHost } from '../../api';
|
||||
|
||||
interface OnTimeUpdateEvent {
|
||||
timestamp: number;
|
||||
isPlaying: boolean;
|
||||
}
|
||||
|
||||
interface VideoProperties {
|
||||
posterUrl: string;
|
||||
videoUrl: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface HistoryVideoProps {
|
||||
id: string;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
onTimeUpdate: (event: OnTimeUpdateEvent) => void;
|
||||
onPause: () => void;
|
||||
onPlay: () => void;
|
||||
}
|
||||
|
||||
const isNullOrUndefined = (object: any): boolean => object === null || object === undefined;
|
||||
|
||||
export const HistoryVideo = ({
|
||||
id,
|
||||
isPlaying: videoIsPlaying,
|
||||
currentTime,
|
||||
onTimeUpdate,
|
||||
onPause,
|
||||
onPlay,
|
||||
}: HistoryVideoProps) => {
|
||||
const apiHost = useApiHost();
|
||||
const videoRef = useRef<HTMLVideoElement>();
|
||||
const [videoHeight, setVideoHeight] = useState<number>(undefined);
|
||||
const [videoProperties, setVideoProperties] = useState<VideoProperties>(undefined);
|
||||
|
||||
const initializeVideoContainerHeight = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
const videoExists = !isNullOrUndefined(video);
|
||||
if (videoExists) {
|
||||
const videoHeight = video.offsetHeight;
|
||||
const videoHasHeight = videoHeight > 0;
|
||||
if (videoHasHeight) {
|
||||
setVideoHeight(videoHeight);
|
||||
}
|
||||
}
|
||||
}, [videoRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
initializeVideoContainerHeight();
|
||||
}, [initializeVideoContainerHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const idExists = !isNullOrUndefined(id);
|
||||
if (idExists) {
|
||||
setVideoProperties({
|
||||
posterUrl: `${apiHost}/api/events/${id}/snapshot.jpg`,
|
||||
videoUrl: `${apiHost}/vod/event/${id}/index.m3u8`,
|
||||
height: videoHeight,
|
||||
});
|
||||
} else {
|
||||
setVideoProperties(undefined);
|
||||
}
|
||||
}, [id, videoHeight]);
|
||||
|
||||
const playVideo = (video: HTMLMediaElement) => {
|
||||
const videoHasNotLoaded = video.readyState <= 1;
|
||||
if (videoHasNotLoaded) {
|
||||
video.load();
|
||||
}
|
||||
|
||||
video.play().catch((e) => {
|
||||
console.error('Fail', e);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
const videoExists = !isNullOrUndefined(video);
|
||||
if (videoExists) {
|
||||
if (videoIsPlaying) {
|
||||
playVideo(video);
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}
|
||||
}, [videoIsPlaying, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
const videoExists = !isNullOrUndefined(video);
|
||||
const hasSeeked = currentTime >= 0;
|
||||
if (videoExists && hasSeeked) {
|
||||
video.currentTime = currentTime;
|
||||
}
|
||||
}, [currentTime, videoRef]);
|
||||
|
||||
const onTimeUpdateHandler = useCallback(
|
||||
(event: Event) => {
|
||||
const target = event.target as HTMLMediaElement;
|
||||
const timeUpdateEvent = {
|
||||
isPlaying: videoIsPlaying,
|
||||
timestamp: target.currentTime,
|
||||
};
|
||||
onTimeUpdate(timeUpdateEvent);
|
||||
},
|
||||
[videoIsPlaying]
|
||||
);
|
||||
|
||||
const Video = useCallback(() => {
|
||||
const videoPropertiesIsUndefined = isNullOrUndefined(videoProperties);
|
||||
if (videoPropertiesIsUndefined) {
|
||||
return <div style={{ height: `${videoHeight}px`, width: '100%' }}></div>;
|
||||
}
|
||||
const { posterUrl, videoUrl, height } = videoProperties;
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
onTimeUpdate={onTimeUpdateHandler}
|
||||
onPause={onPause}
|
||||
onPlay={onPlay}
|
||||
poster={posterUrl}
|
||||
preload='metadata'
|
||||
controls
|
||||
style={height ? { minHeight: `${height}px` } : {}}
|
||||
playsInline
|
||||
>
|
||||
<source type='application/vnd.apple.mpegurl' src={videoUrl} />
|
||||
</video>
|
||||
);
|
||||
}, [videoProperties, videoHeight, videoRef]);
|
||||
|
||||
return <Video />;
|
||||
};
|
||||
81
web/src/components/HistoryViewer/HistoryViewer.tsx
Normal file
81
web/src/components/HistoryViewer/HistoryViewer.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { Fragment, h } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEvents } from '../../api';
|
||||
import { useSearchString } from '../../hooks/useSearchString';
|
||||
import { HistoryHeader } from './HistoryHeader';
|
||||
import Timeline, { TimelineChangeEvent, TimelineEvent, TimelineEventBlock } from '../Timeline/Timeline';
|
||||
import { HistoryVideo } from './HistoryVideo';
|
||||
|
||||
const getLast24Hours = (): number => {
|
||||
const currentTimeEpoch = new Date().getTime();
|
||||
const twentyFourHoursAgoEpoch = new Date(currentTimeEpoch - 24 * 60 * 60 * 1000).getTime();
|
||||
return twentyFourHoursAgoEpoch / 1000;
|
||||
};
|
||||
|
||||
export default function HistoryViewer({ camera }) {
|
||||
const { searchString } = useSearchString(500, `camera=${camera}&after=${getLast24Hours()}`);
|
||||
const { data: events } = useEvents(searchString);
|
||||
|
||||
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>(undefined);
|
||||
const [currentEvent, setCurrentEvent] = useState<TimelineEvent>(undefined);
|
||||
const [isPlaying, setIsPlaying] = useState(undefined);
|
||||
const [currentTime, setCurrentTime] = useState<number>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (events) {
|
||||
const filteredEvents = [...events].reverse().filter((e) => e.end_time !== undefined);
|
||||
setTimelineEvents(filteredEvents);
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
const onTimeUpdateHandler = ({ timestamp, isPlaying }) => {};
|
||||
|
||||
const handleTimelineChange = (event: TimelineChangeEvent) => {
|
||||
if (event.seekComplete) {
|
||||
setCurrentEvent(event.timelineEvent);
|
||||
|
||||
if (isPlaying && event.timelineEvent) {
|
||||
const eventTime = (event.markerTime.getTime() - event.timelineEvent.startTime.getTime()) / 1000;
|
||||
setCurrentTime(eventTime);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
setIsPlaying((previous) => !previous);
|
||||
};
|
||||
|
||||
const onPlayHandler = () => {
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const onPausedHandler = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const handlePrevious = () => {};
|
||||
|
||||
const handleNext = () => {};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Fragment>
|
||||
<div className='relative flex flex-col'>
|
||||
<Fragment>
|
||||
<HistoryHeader event={currentEvent} className='mb-2' />
|
||||
<HistoryVideo
|
||||
id={currentEvent ? currentEvent.id : undefined}
|
||||
isPlaying={isPlaying}
|
||||
currentTime={currentTime}
|
||||
onTimeUpdate={onTimeUpdateHandler}
|
||||
onPlay={onPlayHandler}
|
||||
onPause={onPausedHandler}
|
||||
/>
|
||||
</Fragment>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
<Timeline events={timelineEvents} onChange={handleTimelineChange} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { h } from 'preact';
|
||||
import { Fragment, h } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { longToDate } from '../../utils/dateUtil';
|
||||
import { TimelineBlocks } from './TimelineBlocks';
|
||||
import { DisabledControls, TimelineControls } from './TimelineControls';
|
||||
|
||||
export interface TimelineEvent {
|
||||
start_time: number;
|
||||
@ -20,35 +21,67 @@ export interface TimelineEventBlock extends TimelineEvent {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
interface TimelineProps {
|
||||
events: TimelineEvent[];
|
||||
offset: number;
|
||||
disableMarkerEvents?: boolean;
|
||||
onChange: (timelineChangedEvent: any) => void;
|
||||
export interface TimelineChangeEvent {
|
||||
timelineEvent: TimelineEvent;
|
||||
markerTime: Date;
|
||||
seekComplete: boolean;
|
||||
}
|
||||
|
||||
export default function Timeline({ events, offset, disableMarkerEvents, onChange }: TimelineProps) {
|
||||
interface TimelineProps {
|
||||
events: TimelineEvent[];
|
||||
onChange: (event: TimelineChangeEvent) => void;
|
||||
}
|
||||
|
||||
interface ScrollPermission {
|
||||
allowed: boolean;
|
||||
resetAfterSeeked: boolean;
|
||||
}
|
||||
|
||||
export default function Timeline({ events, onChange }: 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 [isScrollAllowed, setScrollAllowed] = useState(!disableMarkerEvents);
|
||||
const [scrollTimeout, setScrollTimeout] = useState<number | undefined>(undefined);
|
||||
const [scrollPermission, setScrollPermission] = useState<ScrollPermission>({
|
||||
allowed: true,
|
||||
resetAfterSeeked: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setScrollAllowed(!disableMarkerEvents);
|
||||
}, [disableMarkerEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (offset > 0) {
|
||||
scrollToPositionInCurrentEvent(offset);
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [offset]);
|
||||
}, [timeline, currentEvent]);
|
||||
|
||||
const checkEventForOverlap = (firstEvent: TimelineEvent, secondEvent: TimelineEvent) => {
|
||||
if (secondEvent.startTime < firstEvent.endTime) {
|
||||
if (secondEvent.startTime < firstEvent.endTime && secondEvent.startTime > firstEvent.startTime) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@ -85,21 +118,46 @@ export default function Timeline({ events, offset, disableMarkerEvents, onChange
|
||||
index,
|
||||
} as TimelineEventBlock;
|
||||
})
|
||||
.reduce((eventBlocks, current) => {
|
||||
const offset = determineOffset(current, eventBlocks);
|
||||
current.yOffset = offset;
|
||||
return [...eventBlocks, current];
|
||||
}, [] as TimelineEventBlock[]);
|
||||
.reduce((rowMap, current) => {
|
||||
for (let i = 0; i < rowMap.length; i++) {
|
||||
const row = rowMap[i] ?? [];
|
||||
const lastItem = row[row.length - 1];
|
||||
if (lastItem) {
|
||||
const isOverlap = checkEventForOverlap(lastItem, current);
|
||||
if (isOverlap) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rowMap[i] = [...row, current];
|
||||
return rowMap;
|
||||
}
|
||||
rowMap.push([current]);
|
||||
return rowMap;
|
||||
}, [] as TimelineEventBlock[][])
|
||||
.flatMap((r, rowPosition) => {
|
||||
r.forEach((eventBlock) => {
|
||||
const OFFSET_DISTANCE_IN_PIXELS = 10;
|
||||
eventBlock.yOffset = OFFSET_DISTANCE_IN_PIXELS * rowPosition;
|
||||
});
|
||||
return r;
|
||||
})
|
||||
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && timelineOffset) {
|
||||
const timelineEvents = buildTimelineView(events);
|
||||
const lastEventIndex = timelineEvents.length - 1;
|
||||
const recentEvent = timelineEvents[lastEventIndex];
|
||||
|
||||
setTimeline(timelineEvents);
|
||||
setMarkerTime(timelineEvents[lastEventIndex].startTime);
|
||||
setCurrentEvent(timelineEvents[lastEventIndex]);
|
||||
setMarkerTime(recentEvent.startTime);
|
||||
setCurrentEvent(recentEvent);
|
||||
onChange({
|
||||
timelineEvent: recentEvent,
|
||||
markerTime: recentEvent.startTime,
|
||||
seekComplete: true,
|
||||
});
|
||||
}
|
||||
}, [events, timelineOffset]);
|
||||
|
||||
@ -111,24 +169,63 @@ export default function Timeline({ events, offset, disableMarkerEvents, onChange
|
||||
}
|
||||
}, [timeline]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentEvent) {
|
||||
handleChange(true);
|
||||
}
|
||||
}, [currentEvent]);
|
||||
const checkMarkerForEvent = (markerTime: Date) => {
|
||||
markerTime.setMilliseconds(999); // adjust milliseconds to account for drift
|
||||
return [...timeline]
|
||||
.reverse()
|
||||
.find(
|
||||
(timelineEvent) =>
|
||||
timelineEvent.startTime.getTime() <= markerTime.getTime() &&
|
||||
timelineEvent.endTime.getTime() >= markerTime.getTime()
|
||||
);
|
||||
};
|
||||
|
||||
const disableScrollEvent = (milliseconds) => {
|
||||
setScrollAllowed(false);
|
||||
let timeout: NodeJS.Timeout = undefined;
|
||||
timeout = setTimeout(() => {
|
||||
setScrollAllowed(true);
|
||||
clearTimeout(timeout);
|
||||
}, milliseconds);
|
||||
const seekCompleteHandler = (markerTime: Date) => {
|
||||
console.debug('seekCompleteHandler');
|
||||
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 scrollToPosition = (positionX: number) => {
|
||||
if (timelineContainerRef.current) {
|
||||
disableScrollEvent(150);
|
||||
const permission: ScrollPermission = {
|
||||
allowed: true,
|
||||
resetAfterSeeked: true,
|
||||
};
|
||||
setScrollPermission(permission);
|
||||
timelineContainerRef.current.scroll({
|
||||
left: positionX,
|
||||
behavior: 'smooth',
|
||||
@ -136,21 +233,10 @@ export default function Timeline({ events, offset, disableMarkerEvents, onChange
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToPositionInCurrentEvent = (offset: number) => {
|
||||
scrollToPosition(currentEvent.positionX + offset - timelineOffset);
|
||||
setMarkerTime(getCurrentMarkerTime());
|
||||
};
|
||||
|
||||
const scrollToEvent = (event, offset = 0) => {
|
||||
scrollToPosition(event.positionX + offset - timelineOffset);
|
||||
};
|
||||
|
||||
const checkMarkerForEvent = (markerTime) => {
|
||||
return [...timeline]
|
||||
.reverse()
|
||||
.find((timelineEvent) => timelineEvent.startTime <= markerTime && timelineEvent.endTime >= markerTime);
|
||||
};
|
||||
|
||||
const getCurrentMarkerTime = () => {
|
||||
if (timelineContainerRef.current && timeline.length > 0) {
|
||||
const scrollPosition = timelineContainerRef.current.scrollLeft;
|
||||
@ -160,24 +246,6 @@ export default function Timeline({ events, offset, disableMarkerEvents, onChange
|
||||
}
|
||||
};
|
||||
|
||||
const onTimelineScrollHandler = () => {
|
||||
if (isScrollAllowed) {
|
||||
if (timelineContainerRef.current && timeline.length > 0) {
|
||||
clearTimeout(scrollTimeout);
|
||||
const currentMarkerTime = getCurrentMarkerTime();
|
||||
setMarkerTime(currentMarkerTime);
|
||||
handleChange(false);
|
||||
|
||||
setScrollTimeout(
|
||||
setTimeout(() => {
|
||||
const overlappingEvent = checkMarkerForEvent(currentMarkerTime);
|
||||
setCurrentEvent(overlappingEvent);
|
||||
}, 150)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (timelineContainerRef) {
|
||||
const timelineContainerWidth = timelineContainerRef.current.offsetWidth;
|
||||
@ -186,50 +254,64 @@ export default function Timeline({ events, offset, disableMarkerEvents, onChange
|
||||
}
|
||||
}, [timelineContainerRef]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(seekComplete: boolean) => {
|
||||
if (onChange) {
|
||||
onChange({
|
||||
event: currentEvent,
|
||||
markerTime,
|
||||
seekComplete,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, currentEvent, markerTime]
|
||||
);
|
||||
|
||||
const handleViewEvent = (event: TimelineEventBlock) => {
|
||||
setCurrentEvent(event);
|
||||
setMarkerTime(getCurrentMarkerTime());
|
||||
scrollToEvent(event);
|
||||
setMarkerTime(getCurrentMarkerTime());
|
||||
};
|
||||
|
||||
const onPlayPauseHandler = () => {};
|
||||
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 RenderTimelineBlocks = useCallback(() => {
|
||||
if (timelineOffset > 0 && timeline.length > 0) {
|
||||
return <TimelineBlocks timeline={timeline} firstBlockOffset={timelineOffset} onEventClick={handleViewEvent} />;
|
||||
}
|
||||
}, [timeline, timelineOffset]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
<div ref={timelineContainerRef} onScroll={onTimelineScrollHandler} className='overflow-x-auto hide-scroll'>
|
||||
<RenderTimelineBlocks />
|
||||
</div>
|
||||
</div>
|
||||
<div ref={timelineContainerRef} onScroll={onTimelineScrollHandler} className='overflow-x-auto hide-scroll'>
|
||||
{timeline.length > 0 && (
|
||||
<TimelineBlocks timeline={timeline} firstBlockOffset={timelineOffset} onEventClick={handleViewEvent} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TimelineControls
|
||||
disabled={disabledControls}
|
||||
onPrevious={onPreviousHandler}
|
||||
onPlayPause={onPlayPauseHandler}
|
||||
onNext={onNextHandler}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,6 +8,23 @@ interface TimelineBlocksProps {
|
||||
onEventClick: (block: TimelineEventBlock) => void;
|
||||
}
|
||||
export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: TimelineBlocksProps) => {
|
||||
const convertRemToPixels = (rem) => {
|
||||
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
};
|
||||
|
||||
const getMaxYOffset = () => {
|
||||
return timeline.reduce((accumulation, current) => {
|
||||
if (current.yOffset > accumulation) {
|
||||
accumulation = current.yOffset;
|
||||
}
|
||||
return accumulation;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const getTimelineContainerHeight = () => {
|
||||
return getMaxYOffset() + convertRemToPixels(1);
|
||||
};
|
||||
|
||||
const calculateTimelineContainerWidth = () => {
|
||||
if (timeline.length > 0) {
|
||||
const startTimeEpoch = timeline[0].startTime.getTime();
|
||||
@ -18,11 +35,12 @@ export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: Tim
|
||||
|
||||
const onClickHandler = (block: TimelineEventBlock) => onEventClick(block);
|
||||
|
||||
if (timeline && timeline.length > 0) {
|
||||
if (timeline.length > 0) {
|
||||
return (
|
||||
<div
|
||||
className='relative block whitespace-nowrap overflow-x-hidden h-16'
|
||||
className='relative'
|
||||
style={{
|
||||
height: `${getTimelineContainerHeight()}px`,
|
||||
width: `${calculateTimelineContainerWidth()}px`,
|
||||
background: "url('/marker.png')",
|
||||
backgroundPosition: 'center',
|
||||
@ -30,9 +48,17 @@ export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: Tim
|
||||
backgroundRepeat: 'repeat',
|
||||
}}
|
||||
>
|
||||
{timeline.map((block) => (
|
||||
<TimelineBlockView block={block} onClick={onClickHandler} />
|
||||
))}
|
||||
{timeline.map((block) => {
|
||||
return (
|
||||
<TimelineBlockView
|
||||
block={{
|
||||
...block,
|
||||
yOffset: block.yOffset + (getTimelineContainerHeight() - getMaxYOffset()) / 2,
|
||||
}}
|
||||
onClick={onClickHandler}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
34
web/src/components/Timeline/TimelineControls.tsx
Normal file
34
web/src/components/Timeline/TimelineControls.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { h } from 'preact';
|
||||
import Next from '../../icons/Next';
|
||||
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;
|
||||
onPlayPause: () => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
}
|
||||
|
||||
export const TimelineControls = ({ disabled, onPlayPause, onNext, onPrevious }: TimelineControlsProps) => {
|
||||
return (
|
||||
<div className='flex space-x-2 self-center'>
|
||||
<BubbleButton variant='secondary' onClick={onPrevious} disabled={disabled.previous}>
|
||||
<Previous />
|
||||
</BubbleButton>
|
||||
<BubbleButton onClick={onPlayPause}>
|
||||
<Play />
|
||||
</BubbleButton>
|
||||
<BubbleButton variant='secondary' onClick={onNext} disabled={disabled.next}>
|
||||
<Next />
|
||||
</BubbleButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -5,7 +5,7 @@ import { useState } from 'preact/hooks';
|
||||
import { useConfig } from '../api';
|
||||
import { Tabs, TextTab } from '../components/Tabs';
|
||||
import { LiveChip } from '../components/LiveChip';
|
||||
import HistoryViewer from '../components/HistoryViewer';
|
||||
import HistoryViewer from '../components/HistoryViewer/HistoryViewer';
|
||||
import { DebugCamera } from '../components/DebugCamera';
|
||||
|
||||
export default function Camera({ camera }) {
|
||||
@ -35,7 +35,7 @@ export default function Camera({ camera }) {
|
||||
<Heading size='xl' className='mr-2 text-black dark:text-white'>
|
||||
{camera}
|
||||
</Heading>
|
||||
<LiveChip className='text-green-400 border-2 border-solid border-green-400 dark:border-transparent dark:text-white dark:bg-white bg-opacity-40 dark:bg-opacity-10' />
|
||||
<LiveChip className='text-green-400 border-2 border-solid border-green-400 bg-opacity-40 dark:bg-opacity-10' />
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from '../components/Heading';
|
||||
|
||||
export function HistoryHeader({ objectLabel, date, end, camera, className }) {
|
||||
return (
|
||||
<div className={`text-center ${className}`}>
|
||||
<Heading size='lg'>{objectLabel}</Heading>
|
||||
<div>
|
||||
<span>
|
||||
Today, {date.toLocaleTimeString()} - {end ? end.toLocaleTimeString() : 'Incomplete'} · {camera}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user