revamp recordings

This commit is contained in:
Blake Blackshear
2022-05-10 07:48:29 -05:00
parent 78e1782084
commit 691ed6a4c7
5 changed files with 286 additions and 219 deletions

View File

@@ -31,7 +31,10 @@ export default function App() {
<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} />
<AsyncRoute
path="/recording/:camera/:date?/:hour?/:minute?/:second?"
getComponent={Routes.getRecording}
/>
<AsyncRoute path="/debug" getComponent={Routes.getDebug} />
<AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} />
<Cameras default path="/" />

View File

@@ -10,7 +10,13 @@ export function ApiProvider({ children, options }) {
return (
<SWRConfig
value={{
fetcher: (path) => axios.get(path).then((res) => res.data),
fetcher: (arg) => {
if (typeof arg === 'string') {
return axios.get(arg).then((res) => res.data);
}
const [path, params] = arg;
return axios.get(path, { params }).then((res) => res.data);
},
...options,
}}
>

View File

@@ -1,59 +1,39 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { useState, useMemo } from 'preact/hooks';
import {
differenceInSeconds,
getUnixTime,
fromUnixTime,
format,
parseISO,
startOfHour,
differenceInMinutes,
differenceInHours
intervalToDuration,
formatDuration,
endOfDay,
startOfDay,
isSameDay,
} from 'date-fns';
import ArrowDropdown from '../icons/ArrowDropdown';
import ArrowDropup from '../icons/ArrowDropup';
import Link from '../components/Link';
import ActivityIndicator from '../components/ActivityIndicator';
import Menu from '../icons/Menu';
import MenuOpen from '../icons/MenuOpen';
import { useApiHost } from '../api';
import useSWR from 'swr';
export default function RecordingPlaylist({ camera, recordings, selectedDate }) {
const [active, setActive] = useState(true);
const toggle = () => setActive(!active);
const result = [];
for (const recording of recordings.slice().reverse()) {
const date = parseISO(recording.date);
for (const recording of recordings) {
const date = parseISO(recording.day);
result.push(
<ExpandableList
title={format(date, 'MMM d, yyyy')}
events={recording.events}
selected={recording.date === selectedDate}
selected={isSameDay(date, selectedDate)}
>
{recording.recordings
.slice()
.reverse()
.map((item, i) => (
<div key={i} className="mb-2 w-full">
<div
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
i === 0 ? 'border-t border-white border-opacity-50' : ''
}`}
>
<div className="flex-1">
<Link href={`/recording/${camera}/${recording.date}/${item.hour}`} type="text">
{item.hour}:00
</Link>
</div>
<div className="flex-1 text-right">{item.events.length} Events</div>
</div>
{item.events
.slice()
.reverse()
.map((event) => (
<EventCard key={event.id} camera={camera} event={event} delay={item.delay} />
))}
</div>
))}
<DayOfEvents camera={camera} day={recording.day} hours={recording.hours} />
</ExpandableList>
);
}
@@ -79,6 +59,71 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate })
);
}
export function DayOfEvents({ camera, day, hours }) {
const date = parseISO(day);
const { data: events } = useSWR([
`events`,
{
before: getUnixTime(endOfDay(date)),
after: getUnixTime(startOfDay(date)),
camera,
has_clip: '1',
include_thumbnails: 0,
limit: 5000,
},
]);
// maps all the events under the keys for the hour by hour recordings view
const eventMap = useMemo(() => {
const eventMap = {};
for (const hour of hours) {
eventMap[`${day}-${hour.hour}`] = [];
}
if (!events) {
return eventMap;
}
for (const event of events) {
const key = format(fromUnixTime(event.start_time), 'yyyy-MM-dd-HH');
// if the hour of recordings is missing for the event start time, skip it
if (key in eventMap) {
eventMap[key].push(event);
}
}
return eventMap;
}, [events, day, hours]);
if (!events) {
return <ActivityIndicator />;
}
return (
<>
{hours.map((hour, i) => (
<div key={i} className="mb-2 w-full">
<div
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
i === 0 ? 'border-t border-white border-opacity-50' : ''
}`}
>
<div className="flex-1">
<Link href={`/recording/${camera}/${day}/${hour.hour}`} type="text">
{hour.hour}:00
</Link>
</div>
<div className="flex-1 text-right">{hour.events} Events</div>
</div>
{eventMap[`${day}-${hour.hour}`].map((event) => (
<EventCard key={event.id} camera={camera} event={event} />
))}
</div>
))}
</>
);
}
export function ExpandableList({ title, events = 0, children, selected = false }) {
const [active, setActive] = useState(selected);
const toggle = () => setActive(!active);
@@ -89,35 +134,26 @@ export function ExpandableList({ title, events = 0, children, selected = false }
<div className="flex-1 text-right mr-4">{events} Events</div>
<div className="w-6 md:w-10 h-6 md:h-10">{active ? <ArrowDropup /> : <ArrowDropdown />}</div>
</div>
<div className={`bg-gray-800 bg-opacity-50 ${active ? '' : 'hidden'}`}>{children}</div>
{/* Only render the child when expanded to lazy load events for the day */}
{active && <div className={`bg-gray-800 bg-opacity-50`}>{children}</div>}
</div>
);
}
export function EventCard({ camera, event, delay }) {
export function EventCard({ camera, event }) {
const apiHost = useApiHost();
const start = fromUnixTime(event.start_time);
const end = fromUnixTime(event.end_time);
let duration = 'In Progress';
if (event.end_time) {
const end = fromUnixTime(event.end_time);
const hours = differenceInHours(end, start);
const minutes = differenceInMinutes(end, start) - hours * 60;
const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60;
duration = '';
if (hours) duration += `${hours}h `;
if (minutes) duration += `${minutes}m `;
duration += `${seconds}s`;
duration = formatDuration(intervalToDuration({ start, end }));
}
const position = differenceInSeconds(start, startOfHour(start));
const offset = Object.entries(delay)
.map(([p, d]) => (position > p ? d : 0))
.reduce((p, c) => p + c, 0);
const seconds = Math.max(position - offset - 10, 0);
return (
<Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd')}/${format(start, 'HH')}/${seconds}`}>
<Link className="" href={`/recording/${camera}/${format(start, 'yyyy-MM-dd/HH/mm/ss')}`}>
<div className="flex flex-row mb-2">
<div className="w-28 mr-4">
<img className="antialiased" src={`${apiHost}/api/events/${event.id}/thumbnail.jpg`} />
<img className="antialiased" loading="lazy" src={`${apiHost}/api/events/${event.id}/thumbnail.jpg`} />
</div>
<div className="flex flex-row w-full border-b">
<div className="w-full text-gray-700 font-semibold relative pt-0">

View File

@@ -1,5 +1,6 @@
import { h } from 'preact';
import { closestTo, format, parseISO } from 'date-fns';
import { parseISO, endOfHour, startOfHour, getUnixTime } from 'date-fns';
import { useEffect, useMemo } from 'preact/hooks';
import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading';
import RecordingPlaylist from '../components/RecordingPlaylist';
@@ -7,15 +8,106 @@ import VideoPlayer from '../components/VideoPlayer';
import { useApiHost } from '../api';
import useSWR from 'swr';
export default function Recording({ camera, date, hour, seconds }) {
const apiHost = useApiHost();
const { data } = useSWR(`${camera}/recordings`);
export default function Recording({ camera, date, hour = '00', minute = '00', second = '00' }) {
const currentDate = useMemo(
() => (date ? parseISO(`${date}T${hour || '00'}:${minute || '00'}:${second || '00'}`) : new Date()),
[date, hour, minute, second]
);
if (!data) {
const apiHost = useApiHost();
const { data: recordingsSummary } = useSWR(`${camera}/recordings/summary`);
const recordingParams = {
before: getUnixTime(endOfHour(currentDate)),
after: getUnixTime(startOfHour(currentDate)),
};
const { data: recordings } = useSWR([`${camera}/recordings`, recordingParams]);
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
const seekSeconds = useMemo(() => {
if (!recordings) {
return 0;
}
const currentUnix = getUnixTime(currentDate);
const hourStart = getUnixTime(startOfHour(currentDate));
let seekSeconds = 0;
recordings.every((segment) => {
// if the next segment is past the desired time, stop calculating
if (segment.start_time > currentUnix) {
return false;
}
// if the segment starts before the hour, skip the seconds before the hour
const start = segment.start_time < hourStart ? hourStart : segment.start_time;
// if the segment ends after the selected time, use the selected time for end
const end = segment.end_time > currentUnix ? currentUnix : segment.end_time;
seekSeconds += end - start;
return true;
});
return seekSeconds;
}, [recordings, currentDate]);
const playlist = useMemo(() => {
if (!recordingsSummary) {
return [];
}
const selectedDayRecordingData = recordingsSummary.find((s) => !date || s.day === date);
const [year, month, day] = selectedDayRecordingData.day.split('-');
return selectedDayRecordingData.hours
.map((h) => {
return {
name: h.hour,
description: `${camera} recording @ ${h.hour}:00.`,
sources: [
{
src: `${apiHost}/vod/${year}-${month}/${day}/${h.hour}/${camera}/index.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
};
})
.reverse();
}, [apiHost, date, recordingsSummary, camera]);
const playlistIndex = useMemo(() => {
const index = playlist.findIndex((item) => item.name === hour);
if (index === -1) {
return 0;
}
return index;
}, [playlist, hour]);
useEffect(() => {
if (this.player) {
this.player.playlist(playlist);
}
}, [playlist]);
useEffect(() => {
if (this.player) {
this.player.playlist.currentItem(playlistIndex);
}
}, [playlistIndex]);
useEffect(() => {
if (this.player) {
// if the playlist has moved on to the next item, then reset
if (this.player.playlist.currentItem() !== playlistIndex) {
this.player.playlist.currentItem(playlistIndex);
}
this.player.currentTime(seekSeconds);
// try and play since the user is likely to have interacted with the dom
this.player.play();
}
}, [seekSeconds, playlistIndex]);
if (!recordingsSummary) {
return <ActivityIndicator />;
}
if (data.length === 0) {
if (recordingsSummary.length === 0) {
return (
<div className="space-y-4">
<Heading>{camera} Recordings</Heading>
@@ -27,66 +119,18 @@ export default function Recording({ camera, date, hour, seconds }) {
);
}
const recordingDates = data.map((item) => item.date);
const selectedDate = closestTo(
date ? parseISO(date) : new Date(),
recordingDates.map((i) => parseISO(i))
);
const selectedKey = format(selectedDate, 'yyyy-MM-dd');
const [year, month, day] = selectedKey.split('-');
const playlist = [];
const hours = [];
for (const item of data) {
if (item.date === selectedKey) {
for (const recording of item.recordings) {
playlist.push({
name: `${selectedKey} ${recording.hour}:00`,
description: `${camera} recording @ ${recording.hour}:00.`,
sources: [
{
src: `${apiHost}/vod/${year}-${month}/${day}/${recording.hour}/${camera}/index.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
});
hours.push(recording.hour);
}
}
}
const selectedHour = hours.indexOf(hour);
if (this.player) {
this.player.playlist([]);
this.player.playlist(playlist);
this.player.playlist.autoadvance(0);
if (selectedHour !== -1) {
this.player.playlist.currentItem(selectedHour);
if (seconds !== undefined) {
this.player.currentTime(seconds);
}
}
// Force playback rate to be correct
const playbackRate = this.player.playbackRate();
this.player.defaultPlaybackRate(playbackRate);
}
return (
<div className="space-y-4 p-2 px-4">
<Heading>{camera} Recordings</Heading>
<VideoPlayer
onReady={(player) => {
player.on('ratechange', () => player.defaultPlaybackRate(player.playbackRate()));
if (player.playlist) {
player.playlist(playlist);
player.playlist.autoadvance(0);
if (selectedHour !== -1) {
player.playlist.currentItem(selectedHour);
if (seconds !== undefined) {
player.currentTime(seconds);
}
}
player.playlist.currentItem(playlistIndex);
player.currentTime(seekSeconds);
this.player = player;
}
}}
@@ -94,7 +138,7 @@ export default function Recording({ camera, date, hour, seconds }) {
this.player = null;
}}
>
<RecordingPlaylist camera={camera} recordings={data} selectedDate={selectedKey} selectedHour={hour} />
<RecordingPlaylist camera={camera} recordings={recordingsSummary} selectedDate={currentDate} />
</VideoPlayer>
</div>
);