mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-04-26 00:27:40 +03:00
feat: add timeline
This commit is contained in:
parent
87f7b07f84
commit
5125927c41
BIN
web/public/marker.png
Normal file
BIN
web/public/marker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 534 B |
@ -23,10 +23,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">
|
<div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||||
<Sidebar />
|
<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>
|
<Router>
|
||||||
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
|
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
|
||||||
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
|
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCameraV2} />
|
||||||
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
|
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
|
||||||
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
||||||
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} />
|
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} />
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useCallback, useState } from 'preact/hooks';
|
|||||||
|
|
||||||
const MIN_LOAD_TIMEOUT_MS = 200;
|
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 [key, setKey] = useState(Date.now());
|
||||||
const [fps, setFps] = useState(0);
|
const [fps, setFps] = useState(0);
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ export default function AutoUpdatingCameraImage({ camera, searchParams = '', sho
|
|||||||
}, [key, setFps]);
|
}, [key, setFps]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={className}>
|
||||||
<CameraImage camera={camera} onload={handleLoad} searchParams={`cache=${key}&${searchParams}`} />
|
<CameraImage camera={camera} onload={handleLoad} searchParams={`cache=${key}&${searchParams}`} />
|
||||||
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
{showFps ? <span className="text-xs">Displaying at {fps}fps</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
15
web/src/components/LiveChip.jsx
Normal file
15
web/src/components/LiveChip.jsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
|
||||||
|
export function LiveChip({ className }) {
|
||||||
|
return (
|
||||||
|
<div className={`inline relative px-2 py-1 rounded-full ${className}`} style={{ backgroundColor: "rgba(255, 255, 255, 0.1)" }}>
|
||||||
|
<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>
|
||||||
|
<span class="relative inline-flex rounded-full h-3 w-3" style={{ backgroundColor: "rgb(74 222 128)" }}></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span>Live</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
web/src/components/Tabs.jsx
Normal file
34
web/src/components/Tabs.jsx
Normal file
@ -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 (
|
||||||
|
<div className={`flex ${className}`}>
|
||||||
|
<RenderChildren />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextTab({ selected, text, onClick }) {
|
||||||
|
const selectedStyle = selected ? 'text-black bg-white' : "text-white bg-transparent"
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className={`rounded-full px-4 py-2 ${selectedStyle}`}>
|
||||||
|
<span>{text}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
web/src/components/Timeline.jsx
Normal file
186
web/src/components/Timeline.jsx
Normal file
@ -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 (
|
||||||
|
<div className="relative flex items-center h-20" style={{
|
||||||
|
width: `${timelineLength}px`,
|
||||||
|
background: "url('/marker.png')",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundSize: "30px",
|
||||||
|
backgroundRepeat: "repeat-x"
|
||||||
|
}}>
|
||||||
|
{
|
||||||
|
timeline.map((e) => {
|
||||||
|
return (
|
||||||
|
<div key={e.id} className='absolute z-10 rounded-full bg-blue-300 h-2' style={{
|
||||||
|
left: `${e.positionX}px`,
|
||||||
|
width: `${e.seconds}px`
|
||||||
|
}}></div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [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 (
|
||||||
|
<Fragment>
|
||||||
|
<div className='relative flex-grow-1'>
|
||||||
|
<div className='absolute left-0 top-0 h-full w-full' style={{ textAlign: 'center' }}>
|
||||||
|
<div className="h-full" style={{ margin: "0 auto", textAlign: "center" }}>
|
||||||
|
<span className='z-20 text-white'>{markerTime && (<span>{markerTime.toLocaleTimeString()}</span>)}</span>
|
||||||
|
<div className='z-20 h-full absolute' style={{
|
||||||
|
left: "calc(100% / 2)",
|
||||||
|
height: "calc(100% - 24px)",
|
||||||
|
borderRight: "2px solid rgba(252, 211, 77)"
|
||||||
|
}}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={timelineContainerRef} className='overflow-x-auto' onScroll={handleScroll}>
|
||||||
|
<RenderTimeline />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex self-center'>
|
||||||
|
<TextTab onClick={handlePrevious} text={<Previous />} />
|
||||||
|
<TextTab text={<Play />}/>
|
||||||
|
<TextTab onClick={handleNext} text={<Next />} />
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
13
web/src/icons/Next.jsx
Normal file
13
web/src/icons/Next.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Next({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
|
||||||
|
<path fill="white" d="M4,5V19L11,12M18,5V19H20V5M11,5V19L18,12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Next);
|
||||||
12
web/src/icons/Play.jsx
Normal file
12
web/src/icons/Play.jsx
Normal 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);
|
||||||
12
web/src/icons/Previous.jsx
Normal file
12
web/src/icons/Previous.jsx
Normal 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);
|
||||||
163
web/src/routes/Camera_V2.jsx
Normal file
163
web/src/routes/Camera_V2.jsx
Normal file
@ -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 = (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
|
||||||
|
const RenderPlayer = useCallback(() => {
|
||||||
|
if (playerType === "live") {
|
||||||
|
return (
|
||||||
|
<JSMpegPlayer camera={camera} width={liveWidth} height={cameraConfig.live.height} />
|
||||||
|
)
|
||||||
|
} else if (playerType === "debug") {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AutoUpdatingCameraImage camera={camera} searchParams={searchParams} className="w-full" />
|
||||||
|
{/* {optionContent} */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (playerType === "history") {
|
||||||
|
return currentEvent && (
|
||||||
|
<video onClick={handleVideoTouch} poster={`${apiHost}/api/events/${currentEvent.id}/snapshot.jpg`} preload='none' playsInline controls>
|
||||||
|
<source src={`${apiHost}/api/${camera}/start/${currentEvent.startTime}/end/${currentEvent.endTime}/clip.mp4`} />
|
||||||
|
</video>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [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 (
|
||||||
|
<div className='flex 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 transition-opacity duration-300 ${hideBanner && "opacity-0"}`}>
|
||||||
|
<div className="flex pt-4 pl-4 items-center bg-gradient-to-b from-black to-transparent w-full h-16 z10">
|
||||||
|
{ (playerType === "live" || playerType === "debug") && (
|
||||||
|
<Fragment>
|
||||||
|
<Heading size="xl" className="mr-2">{camera}</Heading>
|
||||||
|
<LiveChip />
|
||||||
|
</Fragment>
|
||||||
|
) }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col justify-center h-full'>
|
||||||
|
<div className='relative'>
|
||||||
|
{ currentEvent && <HistoryHeader
|
||||||
|
camera={camera}
|
||||||
|
date={longToDate(currentEvent.start_time)}
|
||||||
|
objectLabel={currentEvent.label}
|
||||||
|
className="mb-2" /> }
|
||||||
|
<RenderPlayer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playerType === "history" && <Timeline camera={camera} onChange={handleTimelineChange} />}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
web/src/routes/HistoryHeader.jsx
Normal file
13
web/src/routes/HistoryHeader.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { h } from 'preact'
|
||||||
|
import Heading from '../components/Heading'
|
||||||
|
|
||||||
|
export function HistoryHeader({ objectLabel, date, camera, className }) {
|
||||||
|
return (
|
||||||
|
<div className={`text-center ${className}`}>
|
||||||
|
<Heading size="lg">{objectLabel}</Heading>
|
||||||
|
<div>
|
||||||
|
<span>Today, {date.toLocaleTimeString()} · {camera}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -8,6 +8,11 @@ export async function getCamera(url, cb, props) {
|
|||||||
return module.default;
|
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) {
|
export async function getEvent(url, cb, props) {
|
||||||
const module = await import('./Event.jsx');
|
const module = await import('./Event.jsx');
|
||||||
return module.default;
|
return module.default;
|
||||||
|
|||||||
1
web/src/utils/dateUtil.js
Normal file
1
web/src/utils/dateUtil.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const longToDate = (long) => new Date(long * 1000);
|
||||||
@ -8,6 +8,9 @@ module.exports = {
|
|||||||
'2xl': '1536px',
|
'2xl': '1536px',
|
||||||
'3xl': '1720px',
|
'3xl': '1720px',
|
||||||
},
|
},
|
||||||
|
flexGrow: {
|
||||||
|
'100': 100
|
||||||
|
}
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
sm: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
|
sm: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user