refactor: pulled components apart and improved logic

This commit is contained in:
JohnMark Sill 2022-02-16 10:24:47 -06:00
parent 21f9e9831b
commit 920c41d4c9
10 changed files with 547 additions and 299 deletions

View 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>
);
};

View File

@ -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>
);
}

View 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()} &middot;
</span>
);
}
return (
<div className={`text-center ${className}`}>
<Heading size='lg'>{title}</Heading>
<div>{subtitle}</div>
</div>
);
};

View 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 />;
};

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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>
);
};

View File

@ -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>

View File

@ -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'} &middot; {camera}
</span>
</div>
</div>
);
}