blakeblackshear.frigate/web/src/components/player/DynamicVideoPlayer.tsx
Nicolas Mowen fc6d6a4e9a
Fix layout shifting (#10313)
* Fix layout shifting

* Change value for current data

* Fix motion scrubbing lockout
2024-03-07 10:11:24 -06:00

547 lines
15 KiB
TypeScript

import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import VideoPlayer from "./VideoPlayer";
import Player from "video.js/dist/types/player";
import TimelineEventOverlay from "../overlay/TimelineDataOverlay";
import { useApiHost } from "@/api";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import ActivityIndicator from "../indicators/activity-indicator";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Recording } from "@/types/record";
import { Preview } from "@/types/preview";
import { DynamicPlayback } from "@/types/playback";
type PlayerMode = "playback" | "scrubbing";
/**
* Dynamically switches between video playback and scrubbing preview player.
*/
type DynamicVideoPlayerProps = {
className?: string;
camera: string;
timeRange: { start: number; end: number };
cameraPreviews: Preview[];
previewOnly?: boolean;
onControllerReady: (controller: DynamicVideoController) => void;
onClick?: () => void;
};
export default function DynamicVideoPlayer({
className,
camera,
timeRange,
cameraPreviews,
previewOnly = false,
onControllerReady,
onClick,
}: DynamicVideoPlayerProps) {
const apiHost = useApiHost();
const { data: config } = useSWR<FrigateConfig>("config");
const timezone = useMemo(
() =>
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
[config],
);
// playback behavior
const tallVideo = useMemo(() => {
if (!config) {
return false;
}
return (
config.cameras[camera].detect.width /
config.cameras[camera].detect.height <
1
);
}, [camera, config]);
// controlling playback
const playerRef = useRef<Player | undefined>(undefined);
const previewRef = useRef<HTMLVideoElement | null>(null);
const [isScrubbing, setIsScrubbing] = useState(previewOnly);
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
undefined,
);
const controller = useMemo(() => {
if (!config) {
return undefined;
}
return new DynamicVideoController(
playerRef,
previewRef,
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
previewOnly ? "scrubbing" : "playback",
setIsScrubbing,
setFocusedItem,
);
}, [camera, config, previewOnly]);
useEffect(() => {
if (!playerRef.current && !previewRef.current) {
return;
}
if (controller) {
onControllerReady(controller);
}
// we only want to fire once when players are ready
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playerRef, previewRef]);
const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true);
// keyboard control
const onKeyboardShortcut = useCallback(
(key: string, down: boolean, repeat: boolean) => {
switch (key) {
case "ArrowLeft":
if (down) {
const currentTime = playerRef.current?.currentTime();
if (currentTime) {
playerRef.current?.currentTime(Math.max(0, currentTime - 5));
}
}
break;
case "ArrowRight":
if (down) {
const currentTime = playerRef.current?.currentTime();
if (currentTime) {
playerRef.current?.currentTime(currentTime + 5);
}
}
break;
case "m":
if (down && !repeat && playerRef.current) {
playerRef.current.muted(!playerRef.current.muted());
}
break;
case " ":
if (down && playerRef.current) {
if (playerRef.current.paused()) {
playerRef.current.play();
} else {
playerRef.current.pause();
}
}
break;
}
},
[playerRef],
);
useKeyboardListener(
["ArrowLeft", "ArrowRight", "m", " "],
onKeyboardShortcut,
);
// initial state
const initialPlaybackSource = useMemo(() => {
const date = new Date(timeRange.start * 1000);
return {
src: `${apiHost}vod/${date.getFullYear()}-${
date.getMonth() + 1
}/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll(
"/",
",",
)}/master.m3u8`,
type: "application/vnd.apple.mpegurl",
};
// we only want to calculate this once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const initialPreview = useMemo(() => {
return cameraPreviews.find(
(preview) =>
preview.camera == camera &&
Math.round(preview.start) >= timeRange.start &&
Math.floor(preview.end) <= timeRange.end,
);
// we only want to calculate this once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [currentPreview, setCurrentPreview] = useState(initialPreview);
const onPreviewSeeked = useCallback(() => {
if (!controller) {
return;
}
controller.finishedSeeking();
if (currentPreview && previewOnly && previewRef.current && onClick) {
setHasRecordingAtTime(
controller.hasRecordingAtTime(
currentPreview.start + previewRef.current.currentTime,
),
);
}
}, [controller, currentPreview, onClick, previewOnly]);
// state of playback player
const recordingParams = useMemo(() => {
return {
before: timeRange.end,
after: timeRange.start,
};
}, [timeRange]);
const { data: recordings } = useSWR<Recording[]>(
previewOnly && onClick == undefined
? null
: [`${camera}/recordings`, recordingParams],
{ revalidateOnFocus: false },
);
useEffect(() => {
if (!controller || (!previewOnly && !recordings)) {
return;
}
const date = new Date(timeRange.start * 1000);
const playbackUri = `${apiHost}vod/${date.getFullYear()}-${
date.getMonth() + 1
}/${date.getDate()}/${date.getHours()}/${camera}/${timezone.replaceAll(
"/",
",",
)}/master.m3u8`;
const preview = cameraPreviews.find(
(preview) =>
preview.camera == camera &&
Math.round(preview.start) >= timeRange.start &&
Math.floor(preview.end) <= timeRange.end,
);
setCurrentPreview(preview);
if (preview && previewRef.current) {
previewRef.current.load();
}
controller.newPlayback({
recordings: recordings ?? [],
playbackUri,
preview,
});
// we only want this to change when recordings update
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controller, recordings]);
if (!controller) {
return <ActivityIndicator />;
}
return (
<div
className={`relative ${className ?? ""} ${onClick ? (hasRecordingAtTime ? "cursor-pointer" : "") : ""}`}
onClick={onClick}
>
{!previewOnly && (
<div
className={`w-full relative ${
currentPreview != undefined && isScrubbing ? "hidden" : "visible"
}`}
>
<VideoPlayer
options={{
preload: "auto",
autoplay: true,
sources: [initialPlaybackSource],
aspectRatio: tallVideo ? "16:9" : undefined,
controlBar: {
remainingTimeDisplay: false,
progressControl: {
seekBar: false,
},
},
}}
seekOptions={{ forward: 10, backward: 5 }}
onReady={(player) => {
playerRef.current = player;
player.on("playing", () => setFocusedItem(undefined));
player.on("timeupdate", () => {
controller.updateProgress(player.currentTime() || 0);
});
player.on("ended", () =>
controller.fireClipChangeEvent("forward"),
);
}}
onDispose={() => {
playerRef.current = undefined;
}}
>
{config && focusedItem && (
<TimelineEventOverlay
timeline={focusedItem}
cameraConfig={config.cameras[camera]}
/>
)}
</VideoPlayer>
</div>
)}
<video
ref={previewRef}
className={`size-full rounded-2xl ${currentPreview != undefined && (previewOnly || isScrubbing) ? "visible" : "hidden"} ${tallVideo ? "aspect-tall" : ""} bg-black`}
preload="auto"
autoPlay
playsInline
muted
disableRemotePlayback
onSeeked={onPreviewSeeked}
onLoadedData={() => controller.previewReady()}
>
{currentPreview != undefined && (
<source src={currentPreview.src} type={currentPreview.type} />
)}
</video>
</div>
);
}
export class DynamicVideoController {
// main state
private playerRef: MutableRefObject<Player | undefined>;
private previewRef: MutableRefObject<HTMLVideoElement | null>;
private setScrubbing: (isScrubbing: boolean) => void;
private setFocusedItem: (timeline: Timeline) => void;
private playerMode: PlayerMode = "playback";
// playback
private recordings: Recording[] = [];
private onPlaybackTimestamp: ((time: number) => void) | undefined = undefined;
private onClipChange: ((dir: "forward" | "backward") => void) | undefined =
undefined;
private annotationOffset: number;
private timeToStart: number | undefined = undefined;
private clipChangeLockout: boolean = true;
// preview
private preview: Preview | undefined = undefined;
private timeToSeek: number | undefined = undefined;
private seeking = false;
private readyToScrub = true;
constructor(
playerRef: MutableRefObject<Player | undefined>,
previewRef: MutableRefObject<HTMLVideoElement | null>,
annotationOffset: number,
defaultMode: PlayerMode,
setScrubbing: (isScrubbing: boolean) => void,
setFocusedItem: (timeline: Timeline) => void,
) {
this.playerRef = playerRef;
this.previewRef = previewRef;
this.annotationOffset = annotationOffset;
this.playerMode = defaultMode;
this.setScrubbing = setScrubbing;
this.setFocusedItem = setFocusedItem;
}
newPlayback(newPlayback: DynamicPlayback) {
this.recordings = newPlayback.recordings;
this.playerRef.current?.src({
src: newPlayback.playbackUri,
type: "application/vnd.apple.mpegurl",
});
if (this.timeToStart) {
this.seekToTimestamp(this.timeToStart);
this.timeToStart = undefined;
}
this.preview = newPlayback.preview;
}
seekToTimestamp(time: number, play: boolean = false) {
if (this.playerMode != "playback") {
this.playerMode = "playback";
this.setScrubbing(false);
this.timeToSeek = undefined;
this.seeking = false;
}
if (this.recordings.length == 0) {
this.timeToStart = time;
}
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;
});
this.playerRef.current?.currentTime(seekSeconds);
if (play) {
this.playerRef.current?.play();
}
}
seekToTimelineItem(timeline: Timeline) {
this.playerRef.current?.pause();
this.seekToTimestamp(timeline.timestamp + this.annotationOffset);
this.setFocusedItem(timeline);
}
updateProgress(playerTime: number) {
if (this.onPlaybackTimestamp) {
// 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;
}
});
this.onPlaybackTimestamp(timestamp);
}
}
onPlayerTimeUpdate(listener: ((timestamp: number) => void) | undefined) {
this.onPlaybackTimestamp = listener;
}
onClipChangedEvent(listener: (dir: "forward" | "backward") => void) {
this.onClipChange = listener;
}
fireClipChangeEvent(dir: "forward" | "backward") {
if (this.onClipChange) {
this.onClipChange(dir);
}
}
scrubToTimestamp(time: number) {
if (!this.preview) {
return;
}
if (!this.readyToScrub) {
return;
}
if (time > this.preview.end) {
if (this.clipChangeLockout && time - this.preview.end < 30) {
return;
}
if (this.playerMode == "scrubbing") {
this.playerMode = "playback";
this.setScrubbing(false);
this.timeToSeek = undefined;
this.seeking = false;
this.readyToScrub = false;
this.clipChangeLockout = true;
this.fireClipChangeEvent("forward");
}
return;
}
if (time < this.preview.start) {
if (this.clipChangeLockout && this.preview.start - time < 30) {
return;
}
if (this.playerMode == "scrubbing") {
this.playerMode = "playback";
this.setScrubbing(false);
this.timeToSeek = undefined;
this.seeking = false;
this.readyToScrub = false;
this.clipChangeLockout = true;
this.fireClipChangeEvent("backward");
}
return;
}
if (this.playerMode != "scrubbing") {
this.playerMode = "scrubbing";
this.playerRef.current?.pause();
this.setScrubbing(true);
}
if (this.seeking) {
this.timeToSeek = time;
} else {
if (this.previewRef.current) {
this.previewRef.current.currentTime = Math.max(
0,
time - this.preview.start,
);
this.seeking = true;
}
}
}
finishedSeeking() {
if (
!this.previewRef.current ||
!this.preview ||
this.playerMode == "playback"
) {
return;
}
this.clipChangeLockout = false;
if (
this.timeToSeek &&
this.timeToSeek != this.previewRef.current?.currentTime
) {
this.previewRef.current.currentTime =
this.timeToSeek - this.preview.start;
} else {
this.seeking = false;
}
}
previewReady() {
this.previewRef.current?.pause();
this.readyToScrub = 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
);
}
}