diff --git a/web/public/marker.png b/web/public/marker.png new file mode 100644 index 000000000..3591e0aec Binary files /dev/null and b/web/public/marker.png differ diff --git a/web/src/App.jsx b/web/src/App.jsx index f6dd29451..d4352a236 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -23,10 +23,10 @@ export default function App() { ) : (
-
+
- + diff --git a/web/src/components/AutoUpdatingCameraImage.jsx b/web/src/components/AutoUpdatingCameraImage.jsx index b5cd2e8d0..9cc598cd4 100644 --- a/web/src/components/AutoUpdatingCameraImage.jsx +++ b/web/src/components/AutoUpdatingCameraImage.jsx @@ -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 ( -
+
{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 ( +
+
+ + + + +
+ Live +
+ ) +} 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)',