feat: Timeline UI (#2830)

This commit is contained in:
JohnMark Sill
2022-02-27 08:04:12 -06:00
committed by GitHub
parent 4004048add
commit 3e07d4eddb
54 changed files with 1950 additions and 50 deletions

View File

@@ -10,7 +10,8 @@ import { DarkModeProvider, DrawerProvider } from './context';
import { FetchStatus, useConfig } from './api';
export default function App() {
const { status } = useConfig();
const { status, data: config } = useConfig();
const cameraComponent = config && config.ui.use_experimental ? Routes.getCameraV2 : Routes.getCamera;
return (
<DarkModeProvider>
<DrawerProvider>
@@ -23,10 +24,10 @@ export default function App() {
) : (
<div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<Sidebar />
<div className="w-full flex-auto p-2 mt-16 px-4 min-w-0">
<div className="w-full flex-auto mt-16 min-w-0">
<Router>
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
<AsyncRoute path="/cameras/:camera" getComponent={cameraComponent} />
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} />

View File

@@ -4,7 +4,7 @@ import { useCallback, useState } from 'preact/hooks';
const MIN_LOAD_TIMEOUT_MS = 200;
export default function AutoUpdatingCameraImage({ camera, searchParams = '', showFps = true }) {
export default function AutoUpdatingCameraImage({ camera, searchParams = '', showFps = true, className }) {
const [key, setKey] = useState(Date.now());
const [fps, setFps] = useState(0);
@@ -20,7 +20,7 @@ export default function AutoUpdatingCameraImage({ camera, searchParams = '', sho
}, [key, setFps]);
return (
<div>
<div className={className}>
<CameraImage camera={camera} onload={handleLoad} searchParams={`cache=${key}&${searchParams}`} />
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
</div>

View File

@@ -0,0 +1,45 @@
import { h } from 'preact';
interface BubbleButtonProps {
variant?: 'primary' | 'secondary';
children?: preact.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

@@ -0,0 +1,74 @@
import { h } from 'preact';
import Link from './Link';
import Switch from './Switch';
import { useCallback, useMemo } from 'preact/hooks';
import { usePersistence } from '../context';
import AutoUpdatingCameraImage from './AutoUpdatingCameraImage';
const emptyObject = Object.freeze({});
export function DebugCamera({ camera }) {
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
const handleSetOption = useCallback(
(id, value) => {
const newOptions = { ...options, [id]: value };
setOptions(newOptions);
},
[options, setOptions]
);
const searchParams = useMemo(
() =>
new URLSearchParams(
Object.keys(options).reduce((memo, key) => {
memo.push([key, options[key] === true ? '1' : '0']);
return memo;
}, [])
),
[options]
);
const optionContent = (
<div className='grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4'>
<Switch
checked={options['bbox']}
id='bbox'
onChange={handleSetOption}
label='Bounding box'
labelPosition='after'
/>
<Switch
checked={options['timestamp']}
id='timestamp'
onChange={handleSetOption}
label='Timestamp'
labelPosition='after'
/>
<Switch checked={options['zones']} id='zones' onChange={handleSetOption} label='Zones' labelPosition='after' />
<Switch checked={options['mask']} id='mask' onChange={handleSetOption} label='Masks' labelPosition='after' />
<Switch
checked={options['motion']}
id='motion'
onChange={handleSetOption}
label='Motion boxes'
labelPosition='after'
/>
<Switch
checked={options['regions']}
id='regions'
onChange={handleSetOption}
label='Regions'
labelPosition='after'
/>
<Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
</div>
);
return (
<div>
<AutoUpdatingCameraImage camera={camera} searchParams={searchParams} />
{optionContent}
</div>
);
}

View File

@@ -1,10 +1,8 @@
import { h, Fragment } from 'preact';
import Button from './Button';
import Heading from './Heading';
import { createPortal } from 'preact/compat';
import { useState, useEffect } from 'preact/hooks';
export default function Dialog({ actions = [], portalRootID = 'dialogs', title, text }) {
export default function Dialog({ children, portalRootID = 'dialogs' }) {
const portalRoot = portalRootID && document.getElementById(portalRootID);
const [show, setShow] = useState(false);
@@ -27,17 +25,7 @@ export default function Dialog({ actions = [], portalRootID = 'dialogs', title,
show ? 'scale-100 opacity-100' : ''
}`}
>
<div className="p-4">
<Heading size="lg">{title}</Heading>
<p>{text}</p>
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
{actions.map(({ color, text, onClick, ...props }, i) => (
<Button className="ml-2" color={color} key={i} onClick={onClick} type="text" {...props}>
{text}
</Button>
))}
</div>
{children}
</div>
</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,134 @@
import { h } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useApiHost } from '../../api';
import { isNullOrUndefined } from '../../utils/objectUtils';
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;
}
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 currentVideo = videoRef.current;
if (currentVideo && !videoHeight) {
const currentVideoHeight = currentVideo.offsetHeight;
if (currentVideoHeight > 0) {
setVideoHeight(currentVideoHeight);
}
}
useEffect(() => {
const idExists = !isNullOrUndefined(id);
if (idExists) {
if (videoRef.current && !videoRef.current.paused) {
videoRef.current = undefined;
}
setVideoProperties({
posterUrl: `${apiHost}/api/events/${id}/snapshot.jpg`,
videoUrl: `${apiHost}/vod/event/${id}/index.m3u8`,
height: videoHeight,
});
} else {
setVideoProperties(undefined);
}
}, [id, videoHeight, videoRef, apiHost]);
useEffect(() => {
const playVideo = (video: HTMLMediaElement) => video.play();
const attemptPlayVideo = (video: HTMLMediaElement) => {
const videoHasNotLoaded = video.readyState <= 1;
if (videoHasNotLoaded) {
video.oncanplay = () => {
playVideo(video);
};
video.load();
} else {
playVideo(video);
}
};
const video = videoRef.current;
const videoExists = !isNullOrUndefined(video);
if (videoExists) {
if (videoIsPlaying) {
attemptPlayVideo(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 && onTimeUpdate(timeUpdateEvent);
},
[videoIsPlaying, onTimeUpdate]
);
const videoPropertiesIsUndefined = isNullOrUndefined(videoProperties);
if (videoPropertiesIsUndefined) {
return <div style={{ height: `${videoHeight}px`, width: '100%' }} />;
}
const { posterUrl, videoUrl, height } = videoProperties;
return (
<video
ref={videoRef}
key={posterUrl}
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>
);
};

View File

@@ -0,0 +1,79 @@
import { Fragment, h } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { useEvents } from '../../api';
import { useSearchString } from '../../hooks/useSearchString';
import { getNowYesterdayInLong } from '../../utils/dateUtil';
import Timeline from '../Timeline/Timeline';
import { TimelineChangeEvent } from '../Timeline/TimelineChangeEvent';
import { TimelineEvent } from '../Timeline/TimelineEvent';
import { HistoryHeader } from './HistoryHeader';
import { HistoryVideo } from './HistoryVideo';
export default function HistoryViewer({ camera }) {
const { searchString } = useSearchString(500, `camera=${camera}&after=${getNowYesterdayInLong()}`);
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 handleTimelineChange = useCallback(
(event: TimelineChangeEvent) => {
if (event.seekComplete) {
setCurrentEvent(event.timelineEvent);
if (isPlaying && event.timelineEvent) {
const eventTime = (event.markerTime.getTime() - event.timelineEvent.startTime.getTime()) / 1000;
setCurrentTime(eventTime);
}
}
},
[isPlaying]
);
const onPlayHandler = () => {
setIsPlaying(true);
};
const onPausedHandler = () => {
setIsPlaying(false);
};
const onPlayPauseHandler = (isPlaying: boolean) => {
setIsPlaying(isPlaying);
};
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}
onPlay={onPlayHandler}
onPause={onPausedHandler}
/>
</Fragment>
</div>
</Fragment>
<Timeline
events={timelineEvents}
isPlaying={isPlaying}
onChange={handleTimelineChange}
onPlayPause={onPlayPauseHandler}
/>
</Fragment>
);
}

View File

@@ -0,0 +1,18 @@
import { h } from 'preact';
export function LiveChip({ className }) {
return (
<div className={`inline relative px-2 py-1 rounded-full ${className}`}>
<div className='relative inline-block w-3 h-3 mr-2'>
<span class='flex h-3 w-3'>
<span
class='animate-ping absolute inline-flex h-full w-full rounded-full opacity-75'
style={{ backgroundColor: 'rgb(74 222 128)' }}
/>
<span class='relative inline-flex rounded-full h-3 w-3' style={{ backgroundColor: 'rgb(74 222 128)' }} />
</span>
</div>
<span>Live</span>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { h } from 'preact';
import Button from './Button';
import Heading from './Heading';
import Dialog from './Dialog';
export default function Prompt({ actions = [], title, text }) {
return (
<Dialog>
<div className='p-4'>
<Heading size='lg'>{title}</Heading>
<p>{text}</p>
</div>
<div className='p-2 flex justify-start flex-row-reverse space-x-2'>
{actions.map(({ color, text, onClick, ...props }, i) => (
<Button className='ml-2' color={color} key={i} onClick={onClick} type='text' {...props}>
{text}
</Button>
))}
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,39 @@
import { h } from 'preact';
import { useCallback, useState } from 'preact/hooks';
export function Tabs({ children, selectedIndex: selectedIndexProp, onChange, className }) {
const [selectedIndex, setSelectedIndex] = useState(selectedIndexProp);
const handleSelected = useCallback(
(index) => () => {
setSelectedIndex(index);
onChange && onChange(index);
},
[onChange]
);
const RenderChildren = useCallback(() => {
return children.map((child, i) => {
child.props.selected = i === selectedIndex;
child.props.onClick = handleSelected(i);
return child;
});
}, [selectedIndex, children, handleSelected]);
return (
<div className={`flex ${className}`}>
<RenderChildren />
</div>
);
}
export function TextTab({ selected, text, onClick }) {
const selectedStyle = selected
? 'text-white bg-blue-500 dark:text-black dark:bg-white'
: 'text-black dark:text-white bg-transparent';
return (
<button onClick={onClick} className={`rounded-full px-4 py-2 ${selectedStyle}`}>
<span>{text}</span>
</button>
);
}

View File

@@ -0,0 +1,4 @@
export interface ScrollPermission {
allowed: boolean;
resetAfterSeeked: boolean;
}

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

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

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

View File

@@ -0,0 +1,7 @@
import { TimelineEvent } from './TimelineEvent';
export interface TimelineChangeEvent {
timelineEvent: TimelineEvent;
markerTime: Date;
seekComplete: boolean;
}

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

View File

@@ -0,0 +1,8 @@
export interface TimelineEvent {
start_time: number;
end_time: number;
startTime: Date;
endTime: Date;
id: string;
label: 'car' | 'person' | 'dog';
}

View File

@@ -0,0 +1,9 @@
import { TimelineEvent } from './TimelineEvent';
export interface TimelineEventBlock extends TimelineEvent {
index: number;
yOffset: number;
width: number;
positionX: number;
seconds: number;
}

View File

@@ -1,6 +1,6 @@
import { h } from 'preact';
import Dialog from '../Dialog';
import { fireEvent, render, screen } from '@testing-library/preact';
import { render, screen } from '@testing-library/preact';
describe('Dialog', () => {
let portal;
@@ -16,23 +16,8 @@ describe('Dialog', () => {
});
test('renders to a portal', async () => {
render(<Dialog title="Tacos" text="This is the dialog" />);
expect(screen.getByText('Tacos')).toBeInTheDocument();
render(<Dialog>Sample</Dialog>);
expect(screen.getByText('Sample')).toBeInTheDocument();
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
});
test('renders action buttons', async () => {
const handleClick = jest.fn();
render(
<Dialog
actions={[
{ color: 'red', text: 'Delete' },
{ text: 'Okay', onClick: handleClick },
]}
title="Tacos"
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
expect(handleClick).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,38 @@
import { h } from 'preact';
import Prompt from '../Prompt';
import { fireEvent, render, screen } from '@testing-library/preact';
describe('Prompt', () => {
let portal;
beforeAll(() => {
portal = document.createElement('div');
portal.id = 'dialogs';
document.body.appendChild(portal);
});
afterAll(() => {
document.body.removeChild(portal);
});
test('renders to a portal', async () => {
render(<Prompt title='Tacos' text='This is the dialog' />);
expect(screen.getByText('Tacos')).toBeInTheDocument();
expect(screen.getByRole('modal').closest('#dialogs')).not.toBeNull();
});
test('renders action buttons', async () => {
const handleClick = jest.fn();
render(
<Prompt
actions={[
{ color: 'red', text: 'Delete' },
{ text: 'Okay', onClick: handleClick },
]}
title='Tacos'
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Okay' }));
expect(handleClick).toHaveBeenCalled();
});
});

13
web/src/icons/Next.jsx Normal file
View File

@@ -0,0 +1,13 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Next({ className = '' }) {
return (
<svg className={`fill-current ${className}`} style="width:24px;height:24px" viewBox="0 0 24 24">
<path d="M4,5V19L11,12M18,5V19H20V5M11,5V19L18,12" />
</svg>
);
}
export default memo(Next);

13
web/src/icons/Pause.jsx Normal file
View File

@@ -0,0 +1,13 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Pause({ className = '' }) {
return (
<svg height='24' viewBox='0 0 24 24' width='24' className={className}>
<path d='M0 0h24v24H0V0z' fill='none' />
<path d='M6 19h4V5H6v14zm8-14v14h4V5h-4z' className='fill-current' />
</svg>
);
}
export default memo(Pause);

12
web/src/icons/Play.jsx Normal file
View File

@@ -0,0 +1,12 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Play({ className = '' }) {
return (
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M8,5.14V19.14L19,12.14L8,5.14Z" />
</svg>
);
}
export default memo(Play);

View File

@@ -0,0 +1,12 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Previous({ className = '' }) {
return (
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M20,5V19L13,12M6,5V19H4V5M13,5V19L6,12" />
</svg>
);
}
export default memo(Previous);

View File

@@ -39,6 +39,15 @@ Could not find a proper tailwind css.
max-width: 70%;
}
.hide-scroll::-webkit-scrollbar {
display: none;
}
.hide-scroll {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/*
Hide some videoplayer controls on mobile devices to
align the video player and bottom control bar properly.

View File

@@ -4,7 +4,7 @@ import Heading from '../components/Heading';
export default function Birdseye() {
return (
<div className="space-y-4">
<div className="space-y-4 p-2 px-4">
<Heading size="2xl">Birdseye</Heading>
<div>
<JSMpegPlayer camera="birdseye" />

View File

@@ -112,7 +112,7 @@ export default function Camera({ camera }) {
}
return (
<div className="space-y-4">
<div className="space-y-4 p-2 px-4">
<Heading size="2xl">{camera}</Heading>
<ButtonsTabbed viewModes={['live', 'debug']} setViewMode={setViewMode} />

View File

@@ -199,7 +199,7 @@ ${Object.keys(objectMaskPoints)
);
return (
<div className="flex-col space-y-4">
<div className="flex-col space-y-4 p-2 px-4">
<Heading size="2xl">{camera} mask & zone creator</Heading>
<Card

View File

@@ -0,0 +1,76 @@
import { h, Fragment } from 'preact';
import JSMpegPlayer from '../components/JSMpegPlayer';
import Heading from '../components/Heading';
import { useState } from 'preact/hooks';
import { useConfig } from '../api';
import { Tabs, TextTab } from '../components/Tabs';
import { LiveChip } from '../components/LiveChip';
import { DebugCamera } from '../components/DebugCamera';
import HistoryViewer from '../components/HistoryViewer/HistoryViewer.tsx';
export default function Camera({ camera }) {
const { data: config } = useConfig();
const [playerType, setPlayerType] = useState('live');
const cameraConfig = config?.cameras[camera];
const handleTabChange = (index) => {
if (index === 0) {
setPlayerType('history');
} else if (index === 1) {
setPlayerType('live');
} else if (index === 2) {
setPlayerType('debug');
}
};
return (
<div className='flex bg-white dark:bg-black w-full h-full justify-center'>
<div className='relative max-w-screen-md flex-grow w-full'>
<div className='absolute top-0 text-white w-full'>
<div className='flex pt-4 pl-4 items-center w-full h-16 z10'>
{(playerType === 'live' || playerType === 'debug') && (
<Fragment>
<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 bg-opacity-40 dark:bg-opacity-10' />
</Fragment>
)}
</div>
</div>
<div className='flex flex-col justify-center h-full'>
<RenderPlayer camera={camera} cameraConfig={cameraConfig} playerType={playerType} />
</div>
<div className='absolute flex justify-center bottom-8 w-full'>
<Tabs selectedIndex={1} onChange={handleTabChange} className='justify'>
<TextTab text='History' />
<TextTab text='Live' />
<TextTab text='Debug' />
</Tabs>
</div>
</div>
</div>
);
}
const RenderPlayer = function ({ camera, cameraConfig, playerType }) {
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height));
if (playerType === 'live') {
return (
<Fragment>
<div>
<JSMpegPlayer camera={camera} width={liveWidth} height={cameraConfig.live.height} />
</div>
</Fragment>
);
} else if (playerType === 'history') {
return <HistoryViewer camera={camera} />;
} else if (playerType === 'debug') {
return <DebugCamera camera={camera} />;
}
return <Fragment />;
};

View File

@@ -15,7 +15,7 @@ export default function Cameras() {
return status !== FetchStatus.LOADED ? (
<ActivityIndicator />
) : (
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4 p-2 px-4">
{Object.entries(config.cameras).map(([camera, conf]) => (
<Camera name={camera} conf={conf} />
))}

View File

@@ -33,7 +33,7 @@ export default function Debug() {
}, [config]);
return (
<div className="space-y-4">
<div className="space-y-4 p-2 px-4">
<Heading>
Debug <span className="text-sm">{service.version}</span>
</Heading>

View File

@@ -10,11 +10,11 @@ import Close from '../icons/Close';
import StarRecording from '../icons/StarRecording';
import Delete from '../icons/Delete';
import Snapshot from '../icons/Snapshot';
import Dialog from '../components/Dialog';
import Heading from '../components/Heading';
import VideoPlayer from '../components/VideoPlayer';
import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
import { FetchStatus, useApiHost, useEvent, useDelete, useRetain } from '../api';
import Prompt from '../components/Prompt';
const ActionButtonGroup = ({ className, isRetained, handleClickRetain, handleClickDelete, close }) => (
<div className={`space-y-2 space-x-2 sm:space-y-0 xs:space-x-4 ${className}`}>
@@ -145,7 +145,7 @@ export default function Event({ eventId, close, scrollRef }) {
</div>
<ActionButtonGroup isRetained={isRetained} handleClickRetain={handleClickRetain} handleClickDelete={handleClickDelete} close={close} className="hidden sm:block" />
{showDialog ? (
<Dialog
<Prompt
onDismiss={handleDismissDeleteDialog}
title="Delete Event?"
text={

View File

@@ -81,7 +81,7 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
[apiHost, handleFilter, pathname, scrollToRef]
);
return (
<div className="space-y-4 w-full">
<div className="space-y-4 p-2 px-4 w-full">
<Heading>Events</Heading>
<Filters onChange={handleFilter} searchParams={searchParams} />
<div className="min-w-0 overflow-auto">

View File

@@ -72,7 +72,7 @@ export default function Recording({ camera, date, hour, seconds }) {
}
return (
<div className="space-y-4">
<div className="space-y-4 p-2 px-4">
<Heading>{camera} Recordings</Heading>
<VideoPlayer

View File

@@ -25,7 +25,7 @@ export default function StyleGuide() {
};
return (
<div>
<div className="p-2 px-4">
<Heading size="md">Button</Heading>
<div className="flex space-x-4 mb-4">
<Button>Default</Button>

View File

@@ -8,6 +8,11 @@ export async function getCamera(url, cb, props) {
return module.default;
}
export async function getCameraV2(url, cb, props) {
const module = await import('./Camera_V2.jsx');
return module.default;
}
export async function getEvent(url, cb, props) {
const module = await import('./Event.jsx');
return module.default;

View File

@@ -0,0 +1,73 @@
import { TimelineEvent } from '../../components/Timeline/TimelineEvent';
import { TimelineEventBlock } from '../../components/Timeline/TimelineEventBlock';
import { epochToLong, longToDate } from '../dateUtil';
export const checkEventForOverlap = (firstEvent: TimelineEvent, secondEvent: TimelineEvent) => {
if (secondEvent.startTime < firstEvent.endTime && secondEvent.startTime > firstEvent.startTime) {
return true;
}
return false;
};
export const getTimelineEventBlocksFromTimelineEvents = (events: TimelineEvent[], xOffset: number): TimelineEventBlock[] => {
const firstEvent = events[0];
const firstEventTime = longToDate(firstEvent.start_time);
return events
.map((e, index) => {
const startTime = longToDate(e.start_time);
const endTime = e.end_time ? longToDate(e.end_time) : new Date();
const seconds = Math.round(Math.abs(endTime.getTime() - startTime.getTime()) / 1000);
const positionX = Math.round(Math.abs(startTime.getTime() - firstEventTime.getTime()) / 1000 + xOffset);
return {
...e,
startTime,
endTime,
width: seconds,
positionX,
index,
} 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((rows, rowPosition) => {
rows.forEach((eventBlock) => {
const OFFSET_DISTANCE_IN_PIXELS = 10;
eventBlock.yOffset = OFFSET_DISTANCE_IN_PIXELS * rowPosition;
});
return rows;
})
.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
}
export const findLargestYOffsetInBlocks = (blocks: TimelineEventBlock[]): number => {
return blocks.reduce((largestYOffset, current) => {
if (current.yOffset > largestYOffset) {
return current.yOffset
}
return largestYOffset;
}, 0)
};
export const getTimelineWidthFromBlocks = (blocks: TimelineEventBlock[], offset: number): number => {
const firstBlock = blocks[0];
if (firstBlock) {
const startTimeEpoch = firstBlock.startTime.getTime();
const endTimeEpoch = Date.now();
const timelineDurationLong = epochToLong(endTimeEpoch - startTimeEpoch);
return timelineDurationLong + offset * 2
}
}

16
web/src/utils/dateUtil.ts Normal file
View File

@@ -0,0 +1,16 @@
export const longToDate = (long: number): Date => new Date(long * 1000);
export const epochToLong = (date: number): number => date / 1000;
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
const getDateTimeYesterday = (dateTime: Date): Date => {
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
}
const getNowYesterday = (): Date => {
return getDateTimeYesterday(new Date());
}
export const getNowYesterdayInLong = (): number => {
return dateToLong(getNowYesterday());
};

View File

@@ -0,0 +1 @@
export const isNullOrUndefined = (object?: unknown): boolean => object === null || object === undefined;

View File

@@ -0,0 +1,15 @@
import { TimelineEvent } from '../../components/Timeline/TimelineEvent';
export const getColorFromTimelineEvent = (event: TimelineEvent) => {
const { label } = event;
if (label === 'car') {
return 'bg-red-400';
} else if (label === 'person') {
return 'bg-blue-400';
} else if (label === 'dog') {
return 'bg-green-400';
}
// unknown label
return 'bg-gray-400';
};

View File

@@ -0,0 +1,3 @@
export const convertRemToPixels = (rem: number): number => {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
}