diff --git a/web/src/components/BubbleButton.tsx b/web/src/components/BubbleButton.tsx
new file mode 100644
index 000000000..f0293bb08
--- /dev/null
+++ b/web/src/components/BubbleButton.tsx
@@ -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 (
+
+ );
+};
diff --git a/web/src/components/HistoryViewer.jsx b/web/src/components/HistoryViewer.jsx
deleted file mode 100644
index 2951e0d2c..000000000
--- a/web/src/components/HistoryViewer.jsx
+++ /dev/null
@@ -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 (
-
- );
- }
- }, [currentEvent, apiHost, camera, videoRef]);
-
- return (
-
- {currentEvent && (
-
-
-
-
-
-
- )}
-
-
-
-
-
- );
-}
diff --git a/web/src/components/HistoryViewer/HistoryHeader.tsx b/web/src/components/HistoryViewer/HistoryHeader.tsx
new file mode 100644
index 000000000..5178eaada
--- /dev/null
+++ b/web/src/components/HistoryViewer/HistoryHeader.tsx
@@ -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 = Event was not found at marker position.;
+ 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 = (
+
+ {isToday ? 'Today' : 'Yesterday'}, {startTime.toLocaleTimeString()} - {endTime.toLocaleTimeString()} ·
+
+ );
+ }
+ return (
+
+ );
+};
diff --git a/web/src/components/HistoryViewer/HistoryVideo.tsx b/web/src/components/HistoryViewer/HistoryVideo.tsx
new file mode 100644
index 000000000..f5dcb6fbd
--- /dev/null
+++ b/web/src/components/HistoryViewer/HistoryVideo.tsx
@@ -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();
+ const [videoHeight, setVideoHeight] = useState(undefined);
+ const [videoProperties, setVideoProperties] = useState(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 ;
+ }
+ const { posterUrl, videoUrl, height } = videoProperties;
+ return (
+
+ );
+ }, [videoProperties, videoHeight, videoRef]);
+
+ return ;
+};
diff --git a/web/src/components/HistoryViewer/HistoryViewer.tsx b/web/src/components/HistoryViewer/HistoryViewer.tsx
new file mode 100644
index 000000000..6d19e0ffe
--- /dev/null
+++ b/web/src/components/HistoryViewer/HistoryViewer.tsx
@@ -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(undefined);
+ const [currentEvent, setCurrentEvent] = useState(undefined);
+ const [isPlaying, setIsPlaying] = useState(undefined);
+ const [currentTime, setCurrentTime] = useState(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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/Timeline/Timeline.tsx b/web/src/components/Timeline/Timeline.tsx
index 564467f0c..9920bbc1a 100644
--- a/web/src/components/Timeline/Timeline.tsx
+++ b/web/src/components/Timeline/Timeline.tsx
@@ -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(undefined);
const [timeline, setTimeline] = useState([]);
+ const [disabledControls, setDisabledControls] = useState({
+ playPause: false,
+ next: true,
+ previous: false,
+ });
const [timelineOffset, setTimelineOffset] = useState(undefined);
const [markerTime, setMarkerTime] = useState(undefined);
const [currentEvent, setCurrentEvent] = useState(undefined);
- const [scrollTimeout, setScrollTimeout] = useState(undefined);
- const [isScrollAllowed, setScrollAllowed] = useState(!disableMarkerEvents);
+ const [scrollTimeout, setScrollTimeout] = useState(undefined);
+ const [scrollPermission, setScrollPermission] = useState({
+ 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 ;
+ }
+ }, [timeline, timelineOffset]);
+
return (
-
-
-
- {markerTime && {markerTime.toLocaleTimeString()}}
-
-
-
-
-
-
+
+
+
+
+ {markerTime && {markerTime.toLocaleTimeString()}}
+
+
+
-
- {timeline.length > 0 && (
-
- )}
-
-
+
+
);
}
diff --git a/web/src/components/Timeline/TimelineBlocks.tsx b/web/src/components/Timeline/TimelineBlocks.tsx
index 8d144ce89..fbd7f8484 100644
--- a/web/src/components/Timeline/TimelineBlocks.tsx
+++ b/web/src/components/Timeline/TimelineBlocks.tsx
@@ -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 (
- {timeline.map((block) => (
-
- ))}
+ {timeline.map((block) => {
+ return (
+
+ );
+ })}
);
}
diff --git a/web/src/components/Timeline/TimelineControls.tsx b/web/src/components/Timeline/TimelineControls.tsx
new file mode 100644
index 000000000..dc0751e1e
--- /dev/null
+++ b/web/src/components/Timeline/TimelineControls.tsx
@@ -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 (
+
+ );
+};
diff --git a/web/src/routes/Camera_V2.jsx b/web/src/routes/Camera_V2.jsx
index 891510d2c..1b28a64b4 100644
--- a/web/src/routes/Camera_V2.jsx
+++ b/web/src/routes/Camera_V2.jsx
@@ -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 }) {
{camera}
-
+
)}
diff --git a/web/src/routes/HistoryHeader.jsx b/web/src/routes/HistoryHeader.jsx
deleted file mode 100644
index 2ae41bf27..000000000
--- a/web/src/routes/HistoryHeader.jsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { h } from 'preact';
-import Heading from '../components/Heading';
-
-export function HistoryHeader({ objectLabel, date, end, camera, className }) {
- return (
-
-
{objectLabel}
-
-
- Today, {date.toLocaleTimeString()} - {end ? end.toLocaleTimeString() : 'Incomplete'} · {camera}
-
-
-
- );
-}