Fix motion review (#10329)

* Break preview only video player out

* Simplify

* Load after current preview changes

* Clear out waiting for seek state

* Start at correct time of hour

* Fix layout for tall video
This commit is contained in:
Nicolas Mowen 2024-03-08 10:56:00 -07:00 committed by GitHub
parent ea5cb4fd8b
commit 3d539c93eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 230 additions and 30 deletions

View File

@ -0,0 +1,217 @@
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview";
import { PreviewPlayback } from "@/types/playback";
type PreviewVideoPlayerProps = {
className?: string;
camera: string;
timeRange: { start: number; end: number };
cameraPreviews: Preview[];
onControllerReady: (controller: PreviewVideoController) => void;
onClick?: () => void;
};
export default function PreviewVideoPlayer({
className,
camera,
timeRange,
cameraPreviews,
onControllerReady,
onClick,
}: PreviewVideoPlayerProps) {
const { data: config } = useSWR<FrigateConfig>("config");
// controlling playback
const previewRef = useRef<HTMLVideoElement | null>(null);
const controller = useMemo(() => {
if (!config || !previewRef.current) {
return undefined;
}
return new PreviewVideoController(camera, previewRef);
// we only care when preview is ready
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [camera, config, previewRef.current]);
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 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();
}, [controller]);
useEffect(() => {
if (!controller) {
return;
}
const preview = cameraPreviews.find(
(preview) =>
preview.camera == camera &&
Math.round(preview.start) >= timeRange.start &&
Math.floor(preview.end) <= timeRange.end,
);
setCurrentPreview(preview);
controller.newPlayback({
preview,
timeRange,
});
// we only want this to change when recordings update
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controller, timeRange]);
useEffect(() => {
if (!currentPreview || !previewRef.current) {
return;
}
previewRef.current.load();
}, [currentPreview, previewRef]);
return (
<div
className={`relative w-full ${className ?? ""} ${onClick ? "cursor-pointer" : ""}`}
onClick={onClick}
>
<video
ref={previewRef}
className={`size-full rounded-2xl bg-black`}
preload="auto"
autoPlay
playsInline
muted
disableRemotePlayback
onSeeked={onPreviewSeeked}
onLoadedData={() => {
if (controller) {
controller.previewReady();
} else {
previewRef.current?.pause();
}
}}
>
{currentPreview != undefined && (
<source src={currentPreview.src} type={currentPreview.type} />
)}
</video>
</div>
);
}
export class PreviewVideoController {
// main state
public camera = "";
private previewRef: MutableRefObject<HTMLVideoElement | null>;
private timeRange: { start: number; end: number } | undefined = undefined;
// preview
private preview: Preview | undefined = undefined;
private timeToSeek: number | undefined = undefined;
private seeking = false;
constructor(
camera: string,
previewRef: MutableRefObject<HTMLVideoElement | null>,
) {
this.camera = camera;
this.previewRef = previewRef;
}
newPlayback(newPlayback: PreviewPlayback) {
this.preview = newPlayback.preview;
this.seeking = false;
this.timeRange = newPlayback.timeRange;
}
scrubToTimestamp(time: number) {
if (!this.preview || !this.timeRange) {
return;
}
if (time < this.preview.start || time > this.preview.end) {
return;
}
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;
}
}
}
setNewPreviewStartTime(time: number) {
this.timeToSeek = time;
}
finishedSeeking() {
if (!this.previewRef.current || !this.preview) {
return;
}
if (
this.timeToSeek &&
this.timeToSeek != this.previewRef.current?.currentTime
) {
this.previewRef.current.currentTime =
this.timeToSeek - this.preview.start;
} else {
this.seeking = false;
}
}
previewReady() {
this.seeking = false;
this.previewRef.current?.pause();
if (this.timeToSeek) {
this.finishedSeeking();
}
}
}

View File

@ -7,3 +7,8 @@ export type DynamicPlayback = {
preview: Preview | undefined;
timeRange: { end: number; start: number };
};
export type PreviewPlayback = {
preview: Preview | undefined;
timeRange: { end: number; start: number };
};

View File

@ -2,9 +2,6 @@ import Logo from "@/components/Logo";
import NewReviewData from "@/components/dynamic/NewReviewData";
import ReviewActionGroup from "@/components/filter/ReviewActionGroup";
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
import DynamicVideoPlayer, {
DynamicVideoController,
} from "@/components/player/DynamicVideoPlayer";
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import ActivityIndicator from "@/components/indicators/activity-indicator";
@ -36,6 +33,9 @@ import { MdCircle } from "react-icons/md";
import useSWR from "swr";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import { Button } from "@/components/ui/button";
import PreviewVideoPlayer, {
PreviewVideoController,
} from "@/components/player/PreviewVideoPlayer";
type EventViewProps = {
reviews?: ReviewSegment[];
@ -531,7 +531,6 @@ function MotionReview({
}: MotionReviewProps) {
const segmentDuration = 30;
const { data: config } = useSWR<FrigateConfig>("config");
const [playerReady, setPlayerReady] = useState(false);
const reviewCameras = useMemo(() => {
if (!config) {
@ -552,7 +551,7 @@ function MotionReview({
return cameras.sort((a, b) => a.ui.order - b.ui.order);
}, [config, filter]);
const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>(
const videoPlayersRef = useRef<{ [camera: string]: PreviewVideoController }>(
{},
);
@ -593,27 +592,6 @@ function MotionReview({
// move to next clip
useEffect(() => {
if (
!videoPlayersRef.current &&
Object.values(videoPlayersRef.current).length > 0
) {
return;
}
const firstController = Object.values(videoPlayersRef.current)[0];
if (firstController) {
firstController.onClipChangedEvent((dir) => {
if (dir == "forward") {
if (selectedRangeIdx < timeRangeSegments.ranges.length - 1) {
setSelectedRangeIdx(selectedRangeIdx + 1);
}
}
});
}
}, [selectedRangeIdx, timeRangeSegments, videoPlayersRef, playerReady]);
useEffect(() => {
if (
currentTime > currentTimeRange.end + 60 ||
@ -624,6 +602,9 @@ function MotionReview({
);
if (index != -1) {
Object.values(videoPlayersRef.current).forEach((controller) => {
controller.setNewPreviewStartTime(currentTime);
});
setSelectedRangeIdx(index);
}
return;
@ -656,17 +637,14 @@ function MotionReview({
grow = "aspect-video";
}
return (
<DynamicVideoPlayer
<PreviewVideoPlayer
key={camera.name}
className={`${grow}`}
camera={camera.name}
timeRange={currentTimeRange}
cameraPreviews={relevantPreviews || []}
previewOnly
preloadRecordings={false}
onControllerReady={(controller) => {
videoPlayersRef.current[camera.name] = controller;
setPlayerReady(true);
}}
onClick={() =>
onSelectReview(`motion,${camera.name},${currentTime}`, false)