mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
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:
parent
ea5cb4fd8b
commit
3d539c93eb
217
web/src/components/player/PreviewVideoPlayer.tsx
Normal file
217
web/src/components/player/PreviewVideoPlayer.tsx
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -7,3 +7,8 @@ export type DynamicPlayback = {
|
|||||||
preview: Preview | undefined;
|
preview: Preview | undefined;
|
||||||
timeRange: { end: number; start: number };
|
timeRange: { end: number; start: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PreviewPlayback = {
|
||||||
|
preview: Preview | undefined;
|
||||||
|
timeRange: { end: number; start: number };
|
||||||
|
};
|
||||||
|
@ -2,9 +2,6 @@ import Logo from "@/components/Logo";
|
|||||||
import NewReviewData from "@/components/dynamic/NewReviewData";
|
import NewReviewData from "@/components/dynamic/NewReviewData";
|
||||||
import ReviewActionGroup from "@/components/filter/ReviewActionGroup";
|
import ReviewActionGroup from "@/components/filter/ReviewActionGroup";
|
||||||
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
|
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
|
||||||
import DynamicVideoPlayer, {
|
|
||||||
DynamicVideoController,
|
|
||||||
} from "@/components/player/DynamicVideoPlayer";
|
|
||||||
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
|
import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer";
|
||||||
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
|
||||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
@ -36,6 +33,9 @@ import { MdCircle } from "react-icons/md";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import PreviewVideoPlayer, {
|
||||||
|
PreviewVideoController,
|
||||||
|
} from "@/components/player/PreviewVideoPlayer";
|
||||||
|
|
||||||
type EventViewProps = {
|
type EventViewProps = {
|
||||||
reviews?: ReviewSegment[];
|
reviews?: ReviewSegment[];
|
||||||
@ -531,7 +531,6 @@ function MotionReview({
|
|||||||
}: MotionReviewProps) {
|
}: MotionReviewProps) {
|
||||||
const segmentDuration = 30;
|
const segmentDuration = 30;
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const [playerReady, setPlayerReady] = useState(false);
|
|
||||||
|
|
||||||
const reviewCameras = useMemo(() => {
|
const reviewCameras = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@ -552,7 +551,7 @@ function MotionReview({
|
|||||||
return cameras.sort((a, b) => a.ui.order - b.ui.order);
|
return cameras.sort((a, b) => a.ui.order - b.ui.order);
|
||||||
}, [config, filter]);
|
}, [config, filter]);
|
||||||
|
|
||||||
const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>(
|
const videoPlayersRef = useRef<{ [camera: string]: PreviewVideoController }>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -593,27 +592,6 @@ function MotionReview({
|
|||||||
|
|
||||||
// move to next clip
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
currentTime > currentTimeRange.end + 60 ||
|
currentTime > currentTimeRange.end + 60 ||
|
||||||
@ -624,6 +602,9 @@ function MotionReview({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
|
Object.values(videoPlayersRef.current).forEach((controller) => {
|
||||||
|
controller.setNewPreviewStartTime(currentTime);
|
||||||
|
});
|
||||||
setSelectedRangeIdx(index);
|
setSelectedRangeIdx(index);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -656,17 +637,14 @@ function MotionReview({
|
|||||||
grow = "aspect-video";
|
grow = "aspect-video";
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<DynamicVideoPlayer
|
<PreviewVideoPlayer
|
||||||
key={camera.name}
|
key={camera.name}
|
||||||
className={`${grow}`}
|
className={`${grow}`}
|
||||||
camera={camera.name}
|
camera={camera.name}
|
||||||
timeRange={currentTimeRange}
|
timeRange={currentTimeRange}
|
||||||
cameraPreviews={relevantPreviews || []}
|
cameraPreviews={relevantPreviews || []}
|
||||||
previewOnly
|
|
||||||
preloadRecordings={false}
|
|
||||||
onControllerReady={(controller) => {
|
onControllerReady={(controller) => {
|
||||||
videoPlayersRef.current[camera.name] = controller;
|
videoPlayersRef.current[camera.name] = controller;
|
||||||
setPlayerReady(true);
|
|
||||||
}}
|
}}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onSelectReview(`motion,${camera.name},${currentTime}`, false)
|
onSelectReview(`motion,${camera.name},${currentTime}`, false)
|
||||||
|
Loading…
Reference in New Issue
Block a user