forked from Github/frigate
Initial Recordings UI
This commit is contained in:
committed by
Blake Blackshear
parent
abbc608ee4
commit
5461308d30
@@ -29,6 +29,7 @@ export default function App() {
|
||||
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
|
||||
<AsyncRoute path="/events/:eventId" getComponent={Routes.getEvent} />
|
||||
<AsyncRoute path="/events" getComponent={Routes.getEvents} />
|
||||
<AsyncRoute path="/recordings/:camera/:date?/:hour?" getComponent={Routes.getRecording} />
|
||||
<AsyncRoute path="/debug" getComponent={Routes.getDebug} />
|
||||
<AsyncRoute path="/styleguide" getComponent={Routes.getStyleGuide} />
|
||||
<Cameras default path="/" />
|
||||
|
||||
@@ -27,6 +27,19 @@ export default function Sidebar() {
|
||||
) : null
|
||||
}
|
||||
</Match>
|
||||
<Match path="/recordings/:camera/:date?/:hour?">
|
||||
{({ matches }) =>
|
||||
matches ? (
|
||||
<Fragment>
|
||||
<Separator />
|
||||
{cameras.map((camera) => (
|
||||
<Destination href={`/recordings/${camera}`} text={camera} />
|
||||
))}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
) : null
|
||||
}
|
||||
</Match>
|
||||
<Destination href="/events" text="Events" />
|
||||
<Destination href="/debug" text="Debug" />
|
||||
<Separator />
|
||||
|
||||
@@ -110,6 +110,11 @@ export function useEvent(eventId, fetchId) {
|
||||
return useFetch(url, fetchId);
|
||||
}
|
||||
|
||||
export function useRecording(camera, fetchId) {
|
||||
const url = `/api/${camera}/recordings`;
|
||||
return useFetch(url, fetchId);
|
||||
}
|
||||
|
||||
export function useConfig(searchParams, fetchId) {
|
||||
const url = `/api/config${searchParams ? `?${searchParams.toString()}` : ''}`;
|
||||
return useFetch(url, fetchId);
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function Button({
|
||||
|
||||
let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
|
||||
ButtonColors[disabled ? 'disabled' : color][type]
|
||||
} font-sans inline-flex font-bold uppercase text-xs px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
|
||||
} font-sans inline-flex font-bold uppercase text-xs px-1.5 md:px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
|
||||
disabled ? 'cursor-not-allowed' : 'focus:ring-2 cursor-pointer'
|
||||
}`;
|
||||
|
||||
|
||||
26
web/src/components/Calendar.jsx
Normal file
26
web/src/components/Calendar.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { h } from 'preact';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export default function Calendar({ date, hours = 0, events = 0, selected = false }) {
|
||||
const bg = selected ? 'bg-blue-500 bg-opacity-80' : 'bg-gray-500';
|
||||
return (
|
||||
<div className="min-w-20 min-h-20 md:min-w-32 md:min-h-32 p-1.5 mb-1 font-medium text-xs md:text-base">
|
||||
<div className="w-20 md:w-32 flex-none rounded-lg text-center shadow-md">
|
||||
<div className="block rounded-lg overflow-hidden text-center text-black">
|
||||
<div className={`${bg} text-white py-0.5`}>{format(date, 'MMM yyyy')}</div>
|
||||
<div className="pt-0.5 bg-white">
|
||||
<span className="text-2xl md:text-5xl font-bold leading-tight">{format(date, 'd')}</span>
|
||||
</div>
|
||||
<div className="text-center bg-white pt-0.5">
|
||||
<span className="md:text-sm">{format(date, 'EEEE')}</span>
|
||||
</div>
|
||||
<div className="pb-0.5 border-l border-r border-b border-white text-center bg-white hidden md:block">
|
||||
<span className="md:text-xs leading-normal">
|
||||
{hours} hrs, {events} events
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
web/src/components/Carousel.jsx
Normal file
65
web/src/components/Carousel.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { h, Component } from 'preact';
|
||||
import Flickity from 'flickity';
|
||||
import 'flickity/css/flickity.css';
|
||||
|
||||
export default class Carousel extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.carousel = null;
|
||||
this.flkty = null;
|
||||
}
|
||||
|
||||
create() {
|
||||
if (this.carousel) {
|
||||
this.flkty = new Flickity(this.carousel, this.props.options);
|
||||
|
||||
if (this.props.flickityRef) {
|
||||
this.props.flickityRef(this.flkty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.flkty) {
|
||||
this.flkty.destroy();
|
||||
this.flkty = null;
|
||||
this.carousel = null;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUpdate() {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.create();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.create();
|
||||
}
|
||||
|
||||
render(props) {
|
||||
return h(
|
||||
this.props.elementType,
|
||||
{
|
||||
className: this.props.className,
|
||||
ref: (c) => {
|
||||
this.carousel = c;
|
||||
},
|
||||
},
|
||||
this.props.children
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Carousel.defaultProps = {
|
||||
options: {},
|
||||
className: '',
|
||||
elementType: 'div',
|
||||
};
|
||||
51
web/src/components/VideoPlayer.jsx
Normal file
51
web/src/components/VideoPlayer.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { h, Component } from 'preact';
|
||||
import videojs from 'video.js';
|
||||
import 'videojs-playlist';
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
const defaultOptions = {
|
||||
controls: true,
|
||||
fluid: true,
|
||||
};
|
||||
|
||||
export default class VideoPlayer extends Component {
|
||||
componentDidMount() {
|
||||
const { options, onReady = () => {} } = this.props;
|
||||
const videoJsOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
};
|
||||
const self = this;
|
||||
this.player = videojs(this.videoNode, videoJsOptions, function onPlayerReady() {
|
||||
onReady(this);
|
||||
this.on('error', () => {
|
||||
console.error('VIDEOJS: ERROR: currentSources:', this.currentSources());
|
||||
});
|
||||
this.on('play', () => {
|
||||
console.log('VIDEOJS: currentSources:', this.currentSources());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.player) {
|
||||
this.player.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
shouldComponentUpdate() {
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { style } = this.props;
|
||||
return (
|
||||
<div style={style}>
|
||||
<div data-vjs-player>
|
||||
<video playsinline ref={(node) => (this.videoNode = node)} className="video-js" />
|
||||
<div className="vjs-playlist" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,10 @@ function Camera({ name }) {
|
||||
const { payload: clipValue, send: sendClips } = useClipsState(name);
|
||||
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
|
||||
const href = `/cameras/${name}`;
|
||||
const buttons = useMemo(() => [{ name: 'Events', href: `/events?camera=${name}` }], [name]);
|
||||
const buttons = useMemo(() => [
|
||||
{ name: 'Events', href: `/events?camera=${name}` },
|
||||
{ name: 'Recordings', href: `/recordings/${name}` }
|
||||
], [name]);
|
||||
const icons = useMemo(
|
||||
() => [
|
||||
{
|
||||
|
||||
114
web/src/routes/Recording.jsx
Normal file
114
web/src/routes/Recording.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { h } from 'preact';
|
||||
import { Link } from 'preact-router/match';
|
||||
import { closestTo, format, isEqual, parseISO } from 'date-fns';
|
||||
import ActivityIndicator from '../components/ActivityIndicator';
|
||||
import Button from '../components/Button';
|
||||
import Calendar from '../components/Calendar';
|
||||
import Carousel from '../components/Carousel';
|
||||
import Heading from '../components/Heading';
|
||||
import VideoPlayer from '../components/VideoPlayer';
|
||||
import { FetchStatus, useApiHost, useRecording } from '../api';
|
||||
|
||||
export default function Recording({ camera, date, hour }) {
|
||||
const apiHost = useApiHost();
|
||||
const { data, status } = useRecording(camera);
|
||||
|
||||
if (status !== FetchStatus.LOADED) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
|
||||
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 calendar = [];
|
||||
const buttons = [];
|
||||
const playlist = [];
|
||||
const hours = [];
|
||||
|
||||
for (const item of data) {
|
||||
const date = parseISO(item.date);
|
||||
const events = item.recordings.map((i) => i.events);
|
||||
calendar.push(
|
||||
<Link href={`/recordings/${camera}/${item.date}`}>
|
||||
<Calendar
|
||||
date={date}
|
||||
hours={events.length}
|
||||
events={events.reduce((a, b) => a + b)}
|
||||
selected={isEqual(selectedDate, date)}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (item.date == selectedKey) {
|
||||
for (const recording of item.recordings) {
|
||||
buttons.push(
|
||||
<Button href={`/recordings/${camera}/${item.date}/${recording.hour}`} type="text">
|
||||
{recording.hour}:00
|
||||
</Button>
|
||||
);
|
||||
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 !== undefined) {
|
||||
this.player.playlist([]);
|
||||
this.player.playlist(playlist);
|
||||
this.player.playlist.autoadvance(0);
|
||||
if (selectedHour !== -1) {
|
||||
this.player.playlist.currentItem(selectedHour);
|
||||
}
|
||||
}
|
||||
|
||||
const selectDate = (flkty) => {
|
||||
flkty.select(recordingDates.indexOf(selectedKey), false, true);
|
||||
};
|
||||
|
||||
const selectHour = (flkty) => {
|
||||
flkty.select(selectedHour, false, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Heading>{camera} Recordings</Heading>
|
||||
|
||||
<Carousel flickityRef={selectDate} options={{ pageDots: false }}>
|
||||
{calendar}
|
||||
</Carousel>
|
||||
|
||||
<VideoPlayer
|
||||
date={selectedKey}
|
||||
onReady={(player) => {
|
||||
if (player.playlist) {
|
||||
player.playlist(playlist);
|
||||
player.playlist.autoadvance(0);
|
||||
if (selectedHour !== -1) {
|
||||
player.playlist.currentItem(selectedHour);
|
||||
}
|
||||
this.player = player;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Carousel flickityRef={selectHour} options={{ pageDots: false }}>
|
||||
{buttons}
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,11 @@ export async function getEvents(url, cb, props) {
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getRecording(url, cb, props) {
|
||||
const module = await import('./Recording.jsx');
|
||||
return module.default;
|
||||
}
|
||||
|
||||
export async function getDebug(url, cb, props) {
|
||||
const module = await import('./Debug.jsx');
|
||||
return module.default;
|
||||
|
||||
Reference in New Issue
Block a user