// This file is part of Moonfire NVR, a security camera network video recorder. // Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. // SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception import React from "react"; import * as api from "../api"; import { useSnackbars } from "../snackbars"; import { Stream } from "../types"; import TableBody from "@material-ui/core/TableBody"; import TableCell from "@material-ui/core/TableCell"; import TableRow, { TableRowProps } from "@material-ui/core/TableRow"; import Skeleton from "@material-ui/core/Skeleton"; import Alert from "@material-ui/core/Alert"; interface Props { stream: Stream; range90k: [number, number] | null; split90k?: number; trimStartAndEnd: boolean; setActiveRecording: ( recording: [Stream, api.Recording, api.VideoSampleEntry] | null ) => void; formatTime: (time90k: number) => string; } const frameRateFmt = new Intl.NumberFormat([], { maximumFractionDigits: 0, }); const sizeFmt = new Intl.NumberFormat([], { maximumFractionDigits: 1, }); interface State { /** * The range to display. * During loading, this can differ from the requested range. */ range90k: [number, number]; response: { status: "skeleton" } | api.FetchResult; } interface RowProps extends TableRowProps { start: React.ReactNode; end: React.ReactNode; resolution: React.ReactNode; fps: React.ReactNode; storage: React.ReactNode; bitrate: React.ReactNode; } const Row = ({ start, end, resolution, fps, storage, bitrate, ...rest }: RowProps) => ( {start} {end} {resolution} {fps} {storage} {bitrate} ); /** * Creates a TableHeader and TableBody with a list of videos * for a given stream and range90k. * * Attempts to minimize reflows while loading. It leaves the existing content * (either nothing or a previous range) for a while before displaying a * skeleton. * * The parent is responsible for creating the greater table. * * When a video is clicked, calls setActiveRecording. */ const VideoList = ({ stream, range90k, split90k, trimStartAndEnd, setActiveRecording, formatTime, }: Props) => { const snackbars = useSnackbars(); const [state, setState] = React.useState(null); React.useEffect(() => { const abort = new AbortController(); const doFetch = async ( signal: AbortSignal, timerId: ReturnType, range90k: [number, number] ) => { const req: api.RecordingsRequest = { cameraUuid: stream.camera.uuid, stream: stream.streamType, startTime90k: range90k[0], endTime90k: range90k[1], split90k, }; let response = await api.recordings(req, { signal }); if (response.status === "success") { // Sort recordings in descending order by start time. response.response.recordings.sort((a, b) => b.startId - a.startId); } clearTimeout(timerId); setState({ range90k, response }); }; if (range90k !== null) { const timerId = setTimeout( () => setState({ range90k, response: { status: "skeleton" } }), 1000 ); doFetch(abort.signal, timerId, range90k); return () => { abort.abort(); clearTimeout(timerId); }; } }, [range90k, split90k, snackbars, stream]); if (state === null) { return null; } let body; if (state.response.status === "skeleton") { body = ( } end={} resolution={} fps={} storage={} bitrate={} /> ); } else if (state.response.status === "error") { body = ( {state.response.message} ); } else if (state.response.status === "success") { const resp = state.response.response; body = resp.recordings.map((r: api.Recording) => { const vse = resp.videoSampleEntries[r.videoSampleEntryId]; const durationSec = (r.endTime90k - r.startTime90k) / 90000; const rate = (r.sampleFileBytes / durationSec) * 0.000008; const start = trimStartAndEnd ? Math.max(r.startTime90k, state.range90k[0]) : r.startTime90k; const end = trimStartAndEnd ? Math.min(r.endTime90k, state.range90k[1]) : r.endTime90k; return ( setActiveRecording([stream, r, vse])} start={formatTime(start)} end={formatTime(end)} resolution={`${vse.width}x${vse.height}`} fps={frameRateFmt.format(r.videoSamples / durationSec)} storage={`${sizeFmt.format(r.sampleFileBytes / 1048576)} MiB`} bitrate={`${sizeFmt.format(rate)} Mbps`} /> ); }); } return ( {stream.camera.shortName} {stream.streamType} {body} ); }; export default VideoList;