+
{showFps ? Displaying at {fps}fps : null}
diff --git a/web/src/components/LiveChip.jsx b/web/src/components/LiveChip.jsx
new file mode 100644
index 000000000..73bd73e86
--- /dev/null
+++ b/web/src/components/LiveChip.jsx
@@ -0,0 +1,15 @@
+import { h } from 'preact';
+
+export function LiveChip({ className }) {
+ return (
+
+ )
+}
diff --git a/web/src/components/Tabs.jsx b/web/src/components/Tabs.jsx
new file mode 100644
index 000000000..16b9ae854
--- /dev/null
+++ b/web/src/components/Tabs.jsx
@@ -0,0 +1,34 @@
+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 = (index) => () => {
+ setSelectedIndex(index);
+ onChange && onChange(index)
+ };
+
+ const RenderChildren = useCallback(() => {
+ return children.map((child, i) => {
+ child.props.selected = i == selectedIndex
+ child.props.onClick = handleSelected(i)
+ return child;
+ })
+ }, [selectedIndex])
+
+ return (
+
+
+
+ )
+}
+
+export function TextTab({ selected, text, onClick }) {
+ const selectedStyle = selected ? 'text-black bg-white' : "text-white bg-transparent"
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/web/src/components/Timeline.jsx b/web/src/components/Timeline.jsx
new file mode 100644
index 000000000..99bfcdb01
--- /dev/null
+++ b/web/src/components/Timeline.jsx
@@ -0,0 +1,186 @@
+import { Fragment, h } from 'preact';
+import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
+import { FetchStatus, useEvents } from '../api';
+import { useSearchString } from '../hooks/useSearchString';
+import { Next } from '../icons/Next';
+import { Play } from '../icons/Play';
+import { Previous } from '../icons/Previous';
+import { TextTab } from './Tabs';
+import { longToDate } from '../utils/dateUtil'
+
+export default function Timeline({ camera, onChange }) {
+ const timelineContainerRef = useRef(undefined);
+
+ const { searchString } = useSearchString(25, `camera=${camera}`);
+ const { data: events, status } = useEvents(searchString);
+ const [timeline, setTimeline] = useState([]);
+ const [timelineOffset, setTimelineOffset] = useState();
+ const [markerTime, setMarkerTime] = useState();
+ const [currentEvent, setCurrentEvent] = useState();
+ const [scrollTimeout, setScrollTimeout] = useState();
+ const [scrollActive, setScrollActive] = useState(true);
+
+ useEffect(() => {
+ if (status === FetchStatus.LOADED && timelineOffset) {
+ const filteredEvents = [...events].reverse().filter(e => e.end_time !== undefined)
+ const firstEvent = events[events.length - 1];
+ if (firstEvent) {
+ setMarkerTime(longToDate(firstEvent.start_time))
+ }
+
+ const firstEventTime = longToDate(firstEvent.start_time);
+ const eventsMap = filteredEvents.map((e, i) => {
+ const startTime = longToDate(e.start_time)
+ const endTime = e.end_time ? longToDate(e.end_time) : new Date();
+ const seconds = Math.round(Math.abs(endTime - startTime) / 1000);
+ const positionX = Math.round((Math.abs(startTime - firstEventTime) / 1000) + timelineOffset);
+ return {
+ ...e,
+ startTime,
+ endTime,
+ seconds,
+ width: seconds,
+ positionX,
+ }
+ })
+
+ const recentEvent = eventsMap[eventsMap.length - 1]
+ const event = {
+ ...recentEvent,
+ id: recentEvent.id,
+ index: eventsMap.length - 1,
+ startTime: recentEvent.start_time,
+ endTime: recentEvent.end_time
+ }
+ setCurrentEvent(event)
+ setTimeline(eventsMap)
+ }
+ }, [events, timelineOffset])
+
+ const checkMarkerForEvent = (markerTime) => {
+ if (!scrollActive) {
+ setScrollActive(true)
+ return
+ }
+
+ if (timeline) {
+ const foundIndex = timeline.findIndex((event => event.startTime <= markerTime && markerTime <= event.endTime))
+ if (foundIndex > -1) {
+ const found = timeline[foundIndex]
+ setCurrentEvent({
+ ...found,
+ id: found.id,
+ index: foundIndex,
+ startTime: found.start_time,
+ endTime: found.end_time
+ })
+ }
+
+ }
+ }
+
+ const handleScroll = event => {
+ clearTimeout(scrollTimeout)
+
+ const scrollPosition = event.target.scrollLeft
+ const startTime = longToDate(timeline[0].start_time)
+ const markerTime = new Date((startTime.getTime()) + (scrollPosition * 1000));
+ setMarkerTime(markerTime)
+
+ setScrollTimeout(setTimeout(() => {
+ checkMarkerForEvent(markerTime)
+ }, 250))
+ }
+
+ useEffect(() => {
+ if (timelineContainerRef) {
+ const timelineContainerWidth = timelineContainerRef.current.offsetWidth
+ const offset = Math.round(timelineContainerWidth / 2)
+ setTimelineOffset(offset);
+ }
+ }, [timelineContainerRef])
+
+ useEffect(() => {
+ onChange && onChange(currentEvent)
+ }, [currentEvent])
+
+ const RenderTimeline = useCallback(() => {
+ if (timeline && timeline.length > 0) {
+ const lastEvent = timeline[timeline.length - 1]
+ const timelineLength = timelineOffset + lastEvent.positionX + lastEvent.width;
+ return (
+
+ {
+ timeline.map((e) => {
+ return (
+
+ )
+ })
+ }
+
+ )
+ }
+ }, [timeline])
+
+ const setNextCurrentEvent = function(offset) {
+ setScrollActive(false)
+ setCurrentEvent(currentEvent => {
+ const index = currentEvent.index + offset;
+ const nextEvent = timeline[index];
+ const positionX = nextEvent.positionX - timelineOffset
+ timelineContainerRef.current.scrollLeft = positionX
+ return {
+ ...nextEvent,
+ id: nextEvent.id,
+ index,
+ startTime: nextEvent.start_time,
+ endTime: nextEvent.end_time
+ }
+ })
+ }
+
+ const handlePrevious = function() {
+ setNextCurrentEvent(-1);
+ }
+
+ const handleNext = function () {
+ setNextCurrentEvent(1);
+ }
+
+ return (
+
+
+
+
+
{markerTime && ({markerTime.toLocaleTimeString()})}
+
+
+
+
+
+
+
+
+
+ } />
+ }/>
+ } />
+
+
+ )
+}
+
+
diff --git a/web/src/icons/Next.jsx b/web/src/icons/Next.jsx
new file mode 100644
index 000000000..3f4ce1dea
--- /dev/null
+++ b/web/src/icons/Next.jsx
@@ -0,0 +1,13 @@
+
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function Next({ className = '' }) {
+ return (
+
+ );
+}
+
+export default memo(Next);
diff --git a/web/src/icons/Play.jsx b/web/src/icons/Play.jsx
new file mode 100644
index 000000000..d0c401b22
--- /dev/null
+++ b/web/src/icons/Play.jsx
@@ -0,0 +1,12 @@
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function Play({ className = '' }) {
+ return (
+
+ );
+}
+
+export default memo(Play);
diff --git a/web/src/icons/Previous.jsx b/web/src/icons/Previous.jsx
new file mode 100644
index 000000000..fbc8ed846
--- /dev/null
+++ b/web/src/icons/Previous.jsx
@@ -0,0 +1,12 @@
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function Previous({ className = '' }) {
+ return (
+
+ );
+}
+
+export default memo(Previous);
diff --git a/web/src/routes/Camera_V2.jsx b/web/src/routes/Camera_V2.jsx
new file mode 100644
index 000000000..90a27a9ce
--- /dev/null
+++ b/web/src/routes/Camera_V2.jsx
@@ -0,0 +1,163 @@
+import { h, Fragment } from 'preact';
+import AutoUpdatingCameraImage from '../components/AutoUpdatingCameraImage';
+import JSMpegPlayer from '../components/JSMpegPlayer';
+import Heading from '../components/Heading';
+import Link from '../components/Link';
+import Switch from '../components/Switch';
+import { usePersistence } from '../context';
+import { useCallback, useMemo, useState } from 'preact/hooks';
+import { useApiHost, useConfig } from '../api';
+import { Tabs, TextTab } from '../components/Tabs';
+import Timeline from '../components/Timeline';
+import { LiveChip } from '../components/LiveChip';
+import { HistoryHeader } from './HistoryHeader';
+import { longToDate } from '../utils/dateUtil'
+
+const emptyObject = Object.freeze({});
+
+export default function Camera({ camera }) {
+ const apiHost = useApiHost();
+ const { data: config } = useConfig();
+
+ const [hideBanner, setHideBanner] = useState(false);
+ const [playerType, setPlayerType] = useState("live");
+
+ const cameraConfig = config?.cameras[camera];
+ const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height))
+ const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
+
+ const [currentEvent, setCurrentEvent] = useState(undefined)
+
+ 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 = (
+
+
+
+
+
+
+
+ Mask & Zone creator
+
+ )
+
+ const RenderPlayer = useCallback(() => {
+ if (playerType === "live") {
+ return (
+
+ )
+ } else if (playerType === "debug") {
+ return (
+
+
+ {/* {optionContent} */}
+
+ );
+ } else if (playerType === "history") {
+ return currentEvent && (
+
+ )
+ }
+ }, [playerType, currentEvent]);
+
+ const handleVideoTouch = () => {
+ setHideBanner(true);
+ }
+
+ const handleTabChange = (index) => {
+ if (index === 0) {
+ setPlayerType("history")
+ }
+ else if (index === 1) {
+ setPlayerType("live")
+ } else if (index === 2) {
+ setPlayerType("debug");
+ }
+ }
+
+ const handleTimelineChange = (event) => {
+ setCurrentEvent(event);
+ }
+
+ return (
+
+
+
+
+ { (playerType === "live" || playerType === "debug") && (
+
+ {camera}
+
+
+ ) }
+
+
+
+
+
+ { currentEvent && }
+
+
+
+ {playerType === "history" &&
}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/routes/HistoryHeader.jsx b/web/src/routes/HistoryHeader.jsx
new file mode 100644
index 000000000..37b5b79f7
--- /dev/null
+++ b/web/src/routes/HistoryHeader.jsx
@@ -0,0 +1,13 @@
+import { h } from 'preact'
+import Heading from '../components/Heading'
+
+export function HistoryHeader({ objectLabel, date, camera, className }) {
+ return (
+
+
{objectLabel}
+
+ Today, {date.toLocaleTimeString()} · {camera}
+
+
+ )
+}
diff --git a/web/src/routes/index.js b/web/src/routes/index.js
index d9b776d87..ef8cfaf9d 100644
--- a/web/src/routes/index.js
+++ b/web/src/routes/index.js
@@ -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;
diff --git a/web/src/utils/dateUtil.js b/web/src/utils/dateUtil.js
new file mode 100644
index 000000000..3e0dfe9c9
--- /dev/null
+++ b/web/src/utils/dateUtil.js
@@ -0,0 +1 @@
+export const longToDate = (long) => new Date(long * 1000);
\ No newline at end of file
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
index 1a1775327..3510fe75d 100644
--- a/web/tailwind.config.js
+++ b/web/tailwind.config.js
@@ -8,6 +8,9 @@ module.exports = {
'2xl': '1536px',
'3xl': '1720px',
},
+ flexGrow: {
+ '100': 100
+ }
},
boxShadow: {
sm: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',