forked from Github/frigate
Use react lazy to break js files into smaller chunks & remove videojs in favor of hls.js (#10431)
* Use dynamic imports to reduce initial load times Remove videojs * Convert to using hls.js instead of videojs * Improve mobile controls experience * Cleanup * Ensure playback rate stays teh same when source changes * Use webp for latest camera image * Switch to hls.js on error * Don't rerun error if hls already tried * Fix error checking * also check for media decode error to fallback to HLS --------- Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
146
web/src/components/player/dynamic/DynamicVideoController.ts
Normal file
146
web/src/components/player/dynamic/DynamicVideoController.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Recording } from "@/types/record";
|
||||
import { DynamicPlayback } from "@/types/playback";
|
||||
import { PreviewController } from "../PreviewPlayer";
|
||||
|
||||
type PlayerMode = "playback" | "scrubbing";
|
||||
|
||||
export class DynamicVideoController {
|
||||
// main state
|
||||
public camera = "";
|
||||
private playerController: HTMLVideoElement;
|
||||
private previewController: PreviewController;
|
||||
private setScrubbing: (isScrubbing: boolean) => void;
|
||||
private setFocusedItem: (timeline: Timeline) => void;
|
||||
private playerMode: PlayerMode = "playback";
|
||||
|
||||
// playback
|
||||
private recordings: Recording[] = [];
|
||||
private annotationOffset: number;
|
||||
private timeToStart: number | undefined = undefined;
|
||||
|
||||
constructor(
|
||||
camera: string,
|
||||
playerController: HTMLVideoElement,
|
||||
previewController: PreviewController,
|
||||
annotationOffset: number,
|
||||
defaultMode: PlayerMode,
|
||||
setScrubbing: (isScrubbing: boolean) => void,
|
||||
setFocusedItem: (timeline: Timeline) => void,
|
||||
) {
|
||||
this.camera = camera;
|
||||
this.playerController = playerController;
|
||||
this.previewController = previewController;
|
||||
this.annotationOffset = annotationOffset;
|
||||
this.playerMode = defaultMode;
|
||||
this.setScrubbing = setScrubbing;
|
||||
this.setFocusedItem = setFocusedItem;
|
||||
}
|
||||
|
||||
newPlayback(newPlayback: DynamicPlayback) {
|
||||
this.recordings = newPlayback.recordings;
|
||||
|
||||
if (this.timeToStart) {
|
||||
this.seekToTimestamp(this.timeToStart);
|
||||
this.timeToStart = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.playerController.pause();
|
||||
}
|
||||
|
||||
seekToTimestamp(time: number, play: boolean = false) {
|
||||
if (this.playerMode != "playback") {
|
||||
this.playerMode = "playback";
|
||||
this.setScrubbing(false);
|
||||
}
|
||||
|
||||
if (
|
||||
this.recordings.length == 0 ||
|
||||
time < this.recordings[0].start_time ||
|
||||
time > this.recordings[this.recordings.length - 1].end_time
|
||||
) {
|
||||
this.timeToStart = time;
|
||||
return;
|
||||
}
|
||||
|
||||
let seekSeconds = 0;
|
||||
(this.recordings || []).every((segment) => {
|
||||
// if the next segment is past the desired time, stop calculating
|
||||
if (segment.start_time > time) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (segment.end_time < time) {
|
||||
seekSeconds += segment.end_time - segment.start_time;
|
||||
return true;
|
||||
}
|
||||
|
||||
seekSeconds +=
|
||||
segment.end_time - segment.start_time - (segment.end_time - time);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (seekSeconds != 0) {
|
||||
this.playerController.currentTime = seekSeconds;
|
||||
|
||||
if (play) {
|
||||
this.playerController.play();
|
||||
} else {
|
||||
this.playerController.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seekToTimelineItem(timeline: Timeline) {
|
||||
this.playerController.pause();
|
||||
this.seekToTimestamp(timeline.timestamp + this.annotationOffset);
|
||||
this.setFocusedItem(timeline);
|
||||
}
|
||||
|
||||
getProgress(playerTime: number): number {
|
||||
// take a player time in seconds and convert to timestamp in timeline
|
||||
let timestamp = 0;
|
||||
let totalTime = 0;
|
||||
(this.recordings || []).every((segment) => {
|
||||
if (totalTime + segment.duration > playerTime) {
|
||||
// segment is here
|
||||
timestamp = segment.start_time + (playerTime - totalTime);
|
||||
return false;
|
||||
} else {
|
||||
totalTime += segment.duration;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
scrubToTimestamp(time: number, saveIfNotReady: boolean = false) {
|
||||
const scrubResult = this.previewController.scrubToTimestamp(time);
|
||||
|
||||
if (!scrubResult && saveIfNotReady) {
|
||||
this.previewController.setNewPreviewStartTime(time);
|
||||
}
|
||||
|
||||
if (scrubResult && this.playerMode != "scrubbing") {
|
||||
this.playerMode = "scrubbing";
|
||||
this.playerController.pause();
|
||||
this.setScrubbing(true);
|
||||
}
|
||||
}
|
||||
|
||||
hasRecordingAtTime(time: number): boolean {
|
||||
if (!this.recordings || this.recordings.length == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.recordings.find(
|
||||
(segment) => segment.start_time <= time && segment.end_time >= time,
|
||||
) != undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default typeof DynamicVideoController;
|
||||
178
web/src/components/player/dynamic/DynamicVideoPlayer.tsx
Normal file
178
web/src/components/player/dynamic/DynamicVideoPlayer.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import TimelineEventOverlay from "../../overlay/TimelineDataOverlay";
|
||||
import { useApiHost } from "@/api";
|
||||
import useSWR from "swr";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Recording } from "@/types/record";
|
||||
import { Preview } from "@/types/preview";
|
||||
import PreviewPlayer, { PreviewController } from "../PreviewPlayer";
|
||||
import { DynamicVideoController } from "./DynamicVideoController";
|
||||
import HlsVideoPlayer from "../HlsVideoPlayer";
|
||||
|
||||
/**
|
||||
* Dynamically switches between video playback and scrubbing preview player.
|
||||
*/
|
||||
type DynamicVideoPlayerProps = {
|
||||
className?: string;
|
||||
camera: string;
|
||||
timeRange: { start: number; end: number };
|
||||
cameraPreviews: Preview[];
|
||||
startTimestamp?: number;
|
||||
onControllerReady: (controller: DynamicVideoController) => void;
|
||||
onTimestampUpdate?: (timestamp: number) => void;
|
||||
onClipEnded?: () => void;
|
||||
};
|
||||
export default function DynamicVideoPlayer({
|
||||
className,
|
||||
camera,
|
||||
timeRange,
|
||||
cameraPreviews,
|
||||
startTimestamp,
|
||||
onControllerReady,
|
||||
onTimestampUpdate,
|
||||
onClipEnded,
|
||||
}: DynamicVideoPlayerProps) {
|
||||
const apiHost = useApiHost();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// playback behavior
|
||||
const wideVideo = useMemo(() => {
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
config.cameras[camera].detect.width /
|
||||
config.cameras[camera].detect.height >
|
||||
1.7
|
||||
);
|
||||
}, [camera, config]);
|
||||
|
||||
// controlling playback
|
||||
|
||||
const playerRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [previewController, setPreviewController] =
|
||||
useState<PreviewController | null>(null);
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const controller = useMemo(() => {
|
||||
if (!config || !playerRef.current || !previewController) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new DynamicVideoController(
|
||||
camera,
|
||||
playerRef.current,
|
||||
previewController,
|
||||
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
|
||||
"playback",
|
||||
setIsScrubbing,
|
||||
setFocusedItem,
|
||||
);
|
||||
// we only want to fire once when players are ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [camera, config, playerRef.current, previewController]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (controller) {
|
||||
onControllerReady(controller);
|
||||
}
|
||||
|
||||
// we only want to fire once when players are ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controller]);
|
||||
|
||||
// initial state
|
||||
|
||||
const [source, setSource] = useState(
|
||||
`${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`,
|
||||
);
|
||||
|
||||
// start at correct time
|
||||
|
||||
const onPlayerLoaded = useCallback(() => {
|
||||
if (!controller || !startTimestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.seekToTimestamp(startTimestamp, true);
|
||||
}, [startTimestamp, controller]);
|
||||
|
||||
const onTimeUpdate = useCallback(
|
||||
(time: number) => {
|
||||
if (!controller || !onTimestampUpdate || time == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
onTimestampUpdate(controller.getProgress(time));
|
||||
},
|
||||
[controller, onTimestampUpdate],
|
||||
);
|
||||
|
||||
// state of playback player
|
||||
|
||||
const recordingParams = useMemo(() => {
|
||||
return {
|
||||
before: timeRange.end,
|
||||
after: timeRange.start,
|
||||
};
|
||||
}, [timeRange]);
|
||||
const { data: recordings } = useSWR<Recording[]>(
|
||||
[`${camera}/recordings`, recordingParams],
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller || !recordings) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSource(
|
||||
`${apiHost}vod/${camera}/start/${timeRange.start}/end/${timeRange.end}/master.m3u8`,
|
||||
);
|
||||
|
||||
controller.newPlayback({
|
||||
recordings: recordings ?? [],
|
||||
});
|
||||
|
||||
// we only want this to change when recordings update
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controller, recordings]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${className ?? ""} cursor-pointer`}>
|
||||
<div className={`w-full relative ${isScrubbing ? "hidden" : "visible"}`}>
|
||||
<HlsVideoPlayer
|
||||
className={` ${wideVideo ? "" : "aspect-video"}`}
|
||||
videoRef={playerRef}
|
||||
currentSource={source}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onPlayerLoaded={onPlayerLoaded}
|
||||
onClipEnded={onClipEnded}
|
||||
>
|
||||
{config && focusedItem && (
|
||||
<TimelineEventOverlay
|
||||
timeline={focusedItem}
|
||||
cameraConfig={config.cameras[camera]}
|
||||
/>
|
||||
)}
|
||||
</HlsVideoPlayer>
|
||||
</div>
|
||||
<PreviewPlayer
|
||||
className={`${isScrubbing ? "visible" : "hidden"} ${className ?? ""}`}
|
||||
camera={camera}
|
||||
timeRange={timeRange}
|
||||
cameraPreviews={cameraPreviews}
|
||||
onControllerReady={(previewController) => {
|
||||
setPreviewController(previewController);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user