Motion review (#10221)

* Break detection grid into separate function

* Implement backward preview jump and jump lockout

* ensure lockout is engaged when starting

* Add preview only mode to make loading more efficeint

* remove scrollbar and match gaps/margins with live view

* Rewrite dynamic player to use html video for preview and fix grid gaps

* consistent check for aspect ratio for tall cameras

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
Nicolas Mowen 2024-03-03 21:21:30 -07:00 committed by GitHub
parent d3f9fd1a60
commit c2a537ce22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 575 additions and 277 deletions

View File

@ -18,6 +18,8 @@ import { Recording } from "@/types/record";
import { Preview } from "@/types/preview"; import { Preview } from "@/types/preview";
import { DynamicPlayback } from "@/types/playback"; import { DynamicPlayback } from "@/types/playback";
type PlayerMode = "playback" | "scrubbing";
/** /**
* Dynamically switches between video playback and scrubbing preview player. * Dynamically switches between video playback and scrubbing preview player.
*/ */
@ -26,6 +28,7 @@ type DynamicVideoPlayerProps = {
camera: string; camera: string;
timeRange: { start: number; end: number }; timeRange: { start: number; end: number };
cameraPreviews: Preview[]; cameraPreviews: Preview[];
previewOnly?: boolean;
onControllerReady?: (controller: DynamicVideoController) => void; onControllerReady?: (controller: DynamicVideoController) => void;
}; };
export default function DynamicVideoPlayer({ export default function DynamicVideoPlayer({
@ -33,6 +36,7 @@ export default function DynamicVideoPlayer({
camera, camera,
timeRange, timeRange,
cameraPreviews, cameraPreviews,
previewOnly = false,
onControllerReady, onControllerReady,
}: DynamicVideoPlayerProps) { }: DynamicVideoPlayerProps) {
const apiHost = useApiHost(); const apiHost = useApiHost();
@ -52,16 +56,15 @@ export default function DynamicVideoPlayer({
return ( return (
config.cameras[camera].detect.width / config.cameras[camera].detect.width /
config.cameras[camera].detect.height < config.cameras[camera].detect.height <
1.7 1
); );
}, [camera, config]); }, [camera, config]);
// controlling playback // controlling playback
const playerRef = useRef<Player | undefined>(undefined); const playerRef = useRef<Player | undefined>(undefined);
const previewRef = useRef<Player | undefined>(undefined); const previewRef = useRef<HTMLVideoElement | null>(null);
const [isScrubbing, setIsScrubbing] = useState(false); const [isScrubbing, setIsScrubbing] = useState(previewOnly);
const [hasPreview, setHasPreview] = useState(false);
const [focusedItem, setFocusedItem] = useState<Timeline | undefined>( const [focusedItem, setFocusedItem] = useState<Timeline | undefined>(
undefined, undefined,
); );
@ -74,10 +77,11 @@ export default function DynamicVideoPlayer({
playerRef, playerRef,
previewRef, previewRef,
(config.cameras[camera]?.detect?.annotation_offset || 0) / 1000, (config.cameras[camera]?.detect?.annotation_offset || 0) / 1000,
previewOnly ? "scrubbing" : "playback",
setIsScrubbing, setIsScrubbing,
setFocusedItem, setFocusedItem,
); );
}, [camera, config]); }, [camera, config, previewOnly]);
// keyboard control // keyboard control
@ -144,18 +148,17 @@ export default function DynamicVideoPlayer({
const initialPreviewSource = useMemo(() => { const initialPreviewSource = useMemo(() => {
const preview = cameraPreviews.find( const preview = cameraPreviews.find(
(preview) => (preview) =>
preview.camera == camera &&
Math.round(preview.start) >= timeRange.start && Math.round(preview.start) >= timeRange.start &&
Math.floor(preview.end) <= timeRange.end, Math.floor(preview.end) <= timeRange.end,
); );
if (preview) { if (preview) {
setHasPreview(true);
return { return {
src: preview.src, src: preview.src,
type: preview.type, type: preview.type,
}; };
} else { } else {
setHasPreview(false);
return undefined; return undefined;
} }
@ -163,6 +166,8 @@ export default function DynamicVideoPlayer({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const [currentPreview, setCurrentPreview] = useState(initialPreviewSource);
// state of playback player // state of playback player
const recordingParams = useMemo(() => { const recordingParams = useMemo(() => {
@ -172,12 +177,12 @@ export default function DynamicVideoPlayer({
}; };
}, [timeRange]); }, [timeRange]);
const { data: recordings } = useSWR<Recording[]>( const { data: recordings } = useSWR<Recording[]>(
[`${camera}/recordings`, recordingParams], previewOnly ? null : [`${camera}/recordings`, recordingParams],
{ revalidateOnFocus: false }, { revalidateOnFocus: false },
); );
useEffect(() => { useEffect(() => {
if (!controller || !recordings || recordings.length == 0) { if (!controller || (!previewOnly && !recordings)) {
return; return;
} }
@ -191,13 +196,14 @@ export default function DynamicVideoPlayer({
const preview = cameraPreviews.find( const preview = cameraPreviews.find(
(preview) => (preview) =>
preview.camera == camera &&
Math.round(preview.start) >= timeRange.start && Math.round(preview.start) >= timeRange.start &&
Math.floor(preview.end) <= timeRange.end, Math.floor(preview.end) <= timeRange.end,
); );
setHasPreview(preview != undefined); setCurrentPreview(preview);
controller.newPlayback({ controller.newPlayback({
recordings, recordings: recordings ?? [],
playbackUri, playbackUri,
preview, preview,
}); });
@ -212,9 +218,10 @@ export default function DynamicVideoPlayer({
return ( return (
<div className={className}> <div className={className}>
{!previewOnly && (
<div <div
className={`w-full relative ${ className={`w-full relative ${
hasPreview && isScrubbing ? "hidden" : "visible" currentPreview != undefined && isScrubbing ? "hidden" : "visible"
}`} }`}
> >
<VideoPlayer <VideoPlayer
@ -237,7 +244,9 @@ export default function DynamicVideoPlayer({
player.on("timeupdate", () => { player.on("timeupdate", () => {
controller.updateProgress(player.currentTime() || 0); controller.updateProgress(player.currentTime() || 0);
}); });
player.on("ended", () => controller.fireClipChangeEvent("forward")); player.on("ended", () =>
controller.fireClipChangeEvent("forward"),
);
if (onControllerReady) { if (onControllerReady) {
onControllerReady(controller); onControllerReady(controller);
@ -255,31 +264,28 @@ export default function DynamicVideoPlayer({
)} )}
</VideoPlayer> </VideoPlayer>
</div> </div>
<div )}
className={`w-full ${hasPreview && isScrubbing ? "visible" : "hidden"}`} <video
ref={previewRef}
className={`size-full rounded-2xl ${currentPreview != undefined && isScrubbing ? "visible" : "hidden"} ${tallVideo ? "aspect-tall" : ""} bg-black`}
preload="auto"
autoPlay
playsInline
muted
onSeeked={() => controller.finishedSeeking()}
onLoadedData={() => controller.previewReady()}
onLoadStart={
previewOnly && onControllerReady
? () => {
onControllerReady(controller);
}
: undefined
}
> >
<VideoPlayer {currentPreview != undefined && (
options={{ <source src={currentPreview.src} type={currentPreview.type} />
preload: "auto", )}
autoplay: true, </video>
controls: false,
muted: true,
loadingSpinner: false,
sources: hasPreview ? initialPreviewSource : null,
aspectRatio: tallVideo ? "16:9" : undefined,
}}
seekOptions={{}}
onReady={(player) => {
previewRef.current = player;
player.pause();
player.on("seeked", () => controller.finishedSeeking());
player.on("loadeddata", () => controller.previewReady());
}}
onDispose={() => {
previewRef.current = undefined;
}}
/>
</div>
</div> </div>
); );
} }
@ -287,10 +293,10 @@ export default function DynamicVideoPlayer({
export class DynamicVideoController { export class DynamicVideoController {
// main state // main state
private playerRef: MutableRefObject<Player | undefined>; private playerRef: MutableRefObject<Player | undefined>;
private previewRef: MutableRefObject<Player | undefined>; private previewRef: MutableRefObject<HTMLVideoElement | null>;
private setScrubbing: (isScrubbing: boolean) => void; private setScrubbing: (isScrubbing: boolean) => void;
private setFocusedItem: (timeline: Timeline) => void; private setFocusedItem: (timeline: Timeline) => void;
private playerMode: "playback" | "scrubbing" = "playback"; private playerMode: PlayerMode = "playback";
// playback // playback
private recordings: Recording[] = []; private recordings: Recording[] = [];
@ -299,6 +305,7 @@ export class DynamicVideoController {
undefined; undefined;
private annotationOffset: number; private annotationOffset: number;
private timeToStart: number | undefined = undefined; private timeToStart: number | undefined = undefined;
private clipChangeLockout: boolean = true;
// preview // preview
private preview: Preview | undefined = undefined; private preview: Preview | undefined = undefined;
@ -308,14 +315,16 @@ export class DynamicVideoController {
constructor( constructor(
playerRef: MutableRefObject<Player | undefined>, playerRef: MutableRefObject<Player | undefined>,
previewRef: MutableRefObject<Player | undefined>, previewRef: MutableRefObject<HTMLVideoElement | null>,
annotationOffset: number, annotationOffset: number,
defaultMode: PlayerMode,
setScrubbing: (isScrubbing: boolean) => void, setScrubbing: (isScrubbing: boolean) => void,
setFocusedItem: (timeline: Timeline) => void, setFocusedItem: (timeline: Timeline) => void,
) { ) {
this.playerRef = playerRef; this.playerRef = playerRef;
this.previewRef = previewRef; this.previewRef = previewRef;
this.annotationOffset = annotationOffset; this.annotationOffset = annotationOffset;
this.playerMode = defaultMode;
this.setScrubbing = setScrubbing; this.setScrubbing = setScrubbing;
this.setFocusedItem = setFocusedItem; this.setFocusedItem = setFocusedItem;
} }
@ -334,12 +343,6 @@ export class DynamicVideoController {
} }
this.preview = newPlayback.preview; this.preview = newPlayback.preview;
if (this.preview && this.previewRef.current) {
this.previewRef.current.src({
src: this.preview.src,
type: this.preview.type,
});
}
} }
seekToTimestamp(time: number, play: boolean = false) { seekToTimestamp(time: number, play: boolean = false) {
@ -427,17 +430,39 @@ export class DynamicVideoController {
} }
if (time > this.preview.end) { if (time > this.preview.end) {
if (this.clipChangeLockout) {
return;
}
if (this.playerMode == "scrubbing") { if (this.playerMode == "scrubbing") {
this.playerMode = "playback"; this.playerMode = "playback";
this.setScrubbing(false); this.setScrubbing(false);
this.timeToSeek = undefined; this.timeToSeek = undefined;
this.seeking = false; this.seeking = false;
this.readyToScrub = false; this.readyToScrub = false;
this.clipChangeLockout = true;
this.fireClipChangeEvent("forward"); this.fireClipChangeEvent("forward");
} }
return; return;
} }
if (time < this.preview.start) {
if (this.clipChangeLockout) {
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") { if (this.playerMode != "scrubbing") {
this.playerMode = "scrubbing"; this.playerMode = "scrubbing";
this.playerRef.current?.pause(); this.playerRef.current?.pause();
@ -447,25 +472,33 @@ export class DynamicVideoController {
if (this.seeking) { if (this.seeking) {
this.timeToSeek = time; this.timeToSeek = time;
} else { } else {
this.previewRef.current?.currentTime( if (this.previewRef.current) {
Math.max(0, time - this.preview.start), this.previewRef.current.currentTime = Math.max(
0,
time - this.preview.start,
); );
this.seeking = true; this.seeking = true;
} }
} }
}
finishedSeeking() { finishedSeeking() {
if (!this.preview || this.playerMode == "playback") { if (
!this.previewRef.current ||
!this.preview ||
this.playerMode == "playback"
) {
return; return;
} }
this.clipChangeLockout = false;
if ( if (
this.timeToSeek && this.timeToSeek &&
this.timeToSeek != this.previewRef.current?.currentTime() this.timeToSeek != this.previewRef.current?.currentTime
) { ) {
this.previewRef.current?.currentTime( this.previewRef.current.currentTime =
this.timeToSeek - this.preview.start, this.timeToSeek - this.preview.start;
);
} else { } else {
this.seeking = false; this.seeking = false;
} }

View File

@ -88,7 +88,7 @@ export default function VideoPlayer({
return ( return (
<div data-vjs-player> <div data-vjs-player>
<div ref={videoRef} /> <div className="rounded-2xl overflow-hidden" ref={videoRef} />
{children} {children}
</div> </div>
); );

View File

@ -5,8 +5,8 @@ import useOverlayState from "@/hooks/use-overlay-state";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { Preview } from "@/types/preview"; import { Preview } from "@/types/preview";
import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review"; import { ReviewFilter, ReviewSegment, ReviewSeverity } from "@/types/review";
import DesktopRecordingView from "@/views/events/DesktopRecordingView";
import EventView from "@/views/events/EventView"; import EventView from "@/views/events/EventView";
import RecordingView from "@/views/events/RecordingView";
import axios from "axios"; import axios from "axios";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
@ -220,7 +220,7 @@ export default function Events() {
if (selectedData) { if (selectedData) {
return ( return (
<DesktopRecordingView <RecordingView
reviewItems={selectedData.cameraSegments} reviewItems={selectedData.cameraSegments}
selectedReview={selectedData.selected} selectedReview={selectedData.selected}
relevantPreviews={selectedData.cameraPreviews} relevantPreviews={selectedData.cameraPreviews}

View File

@ -120,7 +120,7 @@ export function getTimelineItemDescription(timelineItem: Timeline) {
} }
} }
export function getChunkedTimeRange(timestamp: number) { export function getChunkedTimeDay(timestamp: number) {
const endOfThisHour = new Date(); const endOfThisHour = new Date();
endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0); endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0);
const data: { start: number; end: number }[] = []; const data: { start: number; end: number }[] = [];
@ -147,3 +147,33 @@ export function getChunkedTimeRange(timestamp: number) {
return { start: startTimestamp, end, ranges: data }; return { start: startTimestamp, end, ranges: data };
} }
export function getChunkedTimeRange(
startTimestamp: number,
endTimestamp: number,
) {
const endOfThisHour = new Date();
endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0);
const data: { start: number; end: number }[] = [];
const startDay = new Date(startTimestamp * 1000);
startDay.setMinutes(0, 0, 0);
let start = startDay.getTime() / 1000;
let end = 0;
while (end < endTimestamp) {
startDay.setHours(startDay.getHours() + 1);
if (startDay > endOfThisHour || startDay.getTime() / 1000 > endTimestamp) {
break;
}
end = endOfHourOrCurrentTime(startDay.getTime() / 1000);
data.push({
start,
end,
});
start = startDay.getTime() / 1000;
}
return { start: startTimestamp, end: endTimestamp, ranges: data };
}

View File

@ -2,6 +2,9 @@ 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";
@ -16,8 +19,16 @@ import {
ReviewSeverity, ReviewSeverity,
ReviewSummary, ReviewSummary,
} from "@/types/review"; } from "@/types/review";
import { getChunkedTimeRange } from "@/utils/timelineUtil";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { LuFolderCheck } from "react-icons/lu"; import { LuFolderCheck } from "react-icons/lu";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
@ -57,7 +68,6 @@ export default function EventView({
}: EventViewProps) { }: EventViewProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const segmentDuration = 60;
// review counts // review counts
@ -122,11 +132,6 @@ export default function EventView({
}; };
}, [reviewPages]); }, [reviewPages]);
const { alignStartDateToTimeline } = useEventUtils(
reviewItems.all,
segmentDuration,
);
const currentItems = useMemo(() => { const currentItems = useMemo(() => {
const current = reviewItems[severity]; const current = reviewItems[severity];
@ -137,99 +142,7 @@ export default function EventView({
return current; return current;
}, [reviewItems, severity]); }, [reviewItems, severity]);
const showMinimap = useMemo(() => { const pagingObserver = useRef<IntersectionObserver | null>(null);
if (!contentRef.current) {
return false;
}
return contentRef.current.scrollHeight > contentRef.current.clientHeight;
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contentRef.current?.scrollHeight, severity]);
// timeline interaction
const pagingObserver = useRef<IntersectionObserver | null>();
const lastReviewRef = useCallback(
(node: HTMLElement | null) => {
if (isValidating) return;
if (pagingObserver.current) pagingObserver.current.disconnect();
try {
pagingObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !reachedEnd) {
loadNextPage();
}
});
if (node) pagingObserver.current.observe(node);
} catch (e) {
// no op
}
},
[isValidating, reachedEnd, loadNextPage],
);
const [minimap, setMinimap] = useState<string[]>([]);
const minimapObserver = useRef<IntersectionObserver | null>();
useEffect(() => {
const visibleTimestamps = new Set<string>();
minimapObserver.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const start = (entry.target as HTMLElement).dataset.start;
if (!start) {
return;
}
if (entry.isIntersecting) {
visibleTimestamps.add(start);
} else {
visibleTimestamps.delete(start);
}
setMinimap([...visibleTimestamps]);
});
},
{ root: contentRef.current, threshold: isDesktop ? 0.1 : 0.5 },
);
return () => {
minimapObserver.current?.disconnect();
};
}, [contentRef]);
const minimapRef = useCallback(
(node: HTMLElement | null) => {
if (!minimapObserver.current) {
return;
}
try {
if (node) minimapObserver.current.observe(node);
} catch (e) {
// no op
}
},
[minimapObserver],
);
const minimapBounds = useMemo(() => {
const data = {
start: 0,
end: 0,
};
const list = minimap.sort();
if (list.length > 0) {
data.end = parseFloat(list.at(-1) || "0");
data.start = parseFloat(list[0]);
}
return data;
}, [minimap]);
// preview playback
const [previewTime, setPreviewTime] = useState<number>();
const scrollLock = useScrollLockout(contentRef);
// review interaction // review interaction
@ -339,9 +252,188 @@ export default function EventView({
</div> </div>
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
{severity != "significant_motion" && (
<DetectionReview
contentRef={contentRef}
currentItems={currentItems}
reviewItems={reviewItems}
relevantPreviews={relevantPreviews}
pagingObserver={pagingObserver}
selectedReviews={selectedReviews}
severity={severity}
filter={filter}
isValidating={isValidating}
reachedEnd={reachedEnd}
timeRange={timeRange}
loadNextPage={loadNextPage}
markItemAsReviewed={markItemAsReviewed}
onSelectReview={onSelectReview}
pullLatestData={pullLatestData}
/>
)}
{severity == "significant_motion" && (
<MotionReview
contentRef={contentRef}
reviewItems={reviewItems}
relevantPreviews={relevantPreviews}
timeRange={timeRange}
filter={filter}
/>
)}
</div>
</div>
);
}
type DetectionReviewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
currentItems: ReviewSegment[] | null;
reviewItems: {
all: ReviewSegment[];
alert: ReviewSegment[];
detection: ReviewSegment[];
significant_motion: ReviewSegment[];
};
relevantPreviews?: Preview[];
pagingObserver: MutableRefObject<IntersectionObserver | null>;
selectedReviews: string[];
severity: ReviewSeverity;
filter?: ReviewFilter;
isValidating: boolean;
reachedEnd: boolean;
timeRange: { before: number; after: number };
loadNextPage: () => void;
markItemAsReviewed: (id: string) => void;
onSelectReview: (id: string, ctrl: boolean) => void;
pullLatestData: () => void;
};
function DetectionReview({
contentRef,
currentItems,
reviewItems,
relevantPreviews,
pagingObserver,
selectedReviews,
severity,
filter,
isValidating,
reachedEnd,
timeRange,
loadNextPage,
markItemAsReviewed,
onSelectReview,
pullLatestData,
}: DetectionReviewProps) {
const segmentDuration = 60;
// preview
const [previewTime, setPreviewTime] = useState<number>();
// review interaction
const lastReviewRef = useCallback(
(node: HTMLElement | null) => {
if (isValidating) return;
if (pagingObserver.current) pagingObserver.current.disconnect();
try {
pagingObserver.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !reachedEnd) {
loadNextPage();
}
});
if (node) pagingObserver.current.observe(node);
} catch (e) {
// no op
}
},
[isValidating, pagingObserver, reachedEnd, loadNextPage],
);
// timeline interaction
const { alignStartDateToTimeline } = useEventUtils(
reviewItems.all,
segmentDuration,
);
const scrollLock = useScrollLockout(contentRef);
const [minimap, setMinimap] = useState<string[]>([]);
const minimapObserver = useRef<IntersectionObserver | null>(null);
useEffect(() => {
const visibleTimestamps = new Set<string>();
minimapObserver.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const start = (entry.target as HTMLElement).dataset.start;
if (!start) {
return;
}
if (entry.isIntersecting) {
visibleTimestamps.add(start);
} else {
visibleTimestamps.delete(start);
}
setMinimap([...visibleTimestamps]);
});
},
{ root: contentRef.current, threshold: isDesktop ? 0.1 : 0.5 },
);
return () => {
minimapObserver.current?.disconnect();
};
}, [contentRef, minimapObserver]);
const minimapBounds = useMemo(() => {
const data = {
start: 0,
end: 0,
};
const list = minimap.sort();
if (list.length > 0) {
data.end = parseFloat(list.at(-1) || "0");
data.start = parseFloat(list[0]);
}
return data;
}, [minimap]);
const minimapRef = useCallback(
(node: HTMLElement | null) => {
if (!minimapObserver.current) {
return;
}
try {
if (node) minimapObserver.current.observe(node);
} catch (e) {
// no op
}
},
[minimapObserver],
);
const showMinimap = useMemo(() => {
if (!contentRef.current) {
return false;
}
return contentRef.current.scrollHeight > contentRef.current.clientHeight;
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contentRef.current?.scrollHeight, severity]);
return (
<>
<div <div
ref={contentRef} ref={contentRef}
className="flex flex-1 flex-wrap content-start gap-2 overflow-y-auto no-scrollbar" className="flex flex-1 flex-wrap content-start gap-2 md:gap-4 overflow-y-auto no-scrollbar"
> >
{filter?.before == undefined && ( {filter?.before == undefined && (
<NewReviewData <NewReviewData
@ -365,7 +457,7 @@ export default function EventView({
> >
{currentItems ? ( {currentItems ? (
currentItems.map((value, segIdx) => { currentItems.map((value, segIdx) => {
const lastRow = segIdx == reviewItems[severity].length - 1; const lastRow = segIdx == currentItems.length - 1;
const selected = selectedReviews.includes(value.id); const selected = selectedReviews.includes(value.id);
return ( return (
@ -374,8 +466,7 @@ export default function EventView({
ref={lastRow ? lastReviewRef : minimapRef} ref={lastRow ? lastReviewRef : minimapRef}
data-start={value.start_time} data-start={value.start_time}
data-segment-start={ data-segment-start={
alignStartDateToTimeline(value.start_time) - alignStartDateToTimeline(value.start_time) - segmentDuration
segmentDuration
} }
className={`outline outline-offset-1 rounded-lg shadow-none transition-all my-1 md:my-0 ${selected ? `outline-4 shadow-[0_0_6px_1px] outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-0 duration-500"}`} className={`outline outline-offset-1 rounded-lg shadow-none transition-all my-1 md:my-0 ${selected ? `outline-4 shadow-[0_0_6px_1px] outline-severity_${value.severity} shadow-severity_${value.severity}` : "outline-0 duration-500"}`}
> >
@ -414,7 +505,151 @@ export default function EventView({
contentRef={contentRef} contentRef={contentRef}
/> />
</div> </div>
</div> </>
</div> );
}
type MotionReviewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
reviewItems: {
all: ReviewSegment[];
alert: ReviewSegment[];
detection: ReviewSegment[];
significant_motion: ReviewSegment[];
};
relevantPreviews?: Preview[];
timeRange: { before: number; after: number };
filter?: ReviewFilter;
};
function MotionReview({
contentRef,
reviewItems,
relevantPreviews,
timeRange,
filter,
}: MotionReviewProps) {
const segmentDuration = 30;
const { data: config } = useSWR<FrigateConfig>("config");
const [playerReady, setPlayerReady] = useState(false);
const reviewCameras = useMemo(() => {
if (!config) {
return [];
}
let cameras;
if (!filter || !filter.cameras) {
cameras = Object.values(config.cameras);
} else {
const filteredCams = filter.cameras;
cameras = Object.values(config.cameras).filter((cam) =>
filteredCams.includes(cam.name),
);
}
return cameras.sort((a, b) => a.ui.order - b.ui.order);
}, [config, filter]);
const videoPlayersRef = useRef<{ [camera: string]: DynamicVideoController }>(
{},
);
// timeline time
const lastFullHour = useMemo(() => {
const end = new Date(timeRange.before * 1000);
end.setMinutes(0, 0, 0);
return end.getTime() / 1000;
}, [timeRange]);
const timeRangeSegments = useMemo(
() => getChunkedTimeRange(timeRange.after, lastFullHour),
[lastFullHour, timeRange],
);
const [selectedRangeIdx, setSelectedRangeIdx] = useState(
timeRangeSegments.ranges.length - 1,
);
const [currentTime, setCurrentTime] = useState<number>(
timeRangeSegments.ranges[selectedRangeIdx].start,
);
// 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" &&
selectedRangeIdx < timeRangeSegments.ranges.length - 1
) {
setSelectedRangeIdx(selectedRangeIdx + 1);
} else if (selectedRangeIdx > 0) {
setSelectedRangeIdx(selectedRangeIdx - 1);
}
});
}
}, [selectedRangeIdx, timeRangeSegments, videoPlayersRef, playerReady]);
useEffect(() => {
Object.values(videoPlayersRef.current).forEach((controller) => {
controller.scrubToTimestamp(currentTime);
});
}, [currentTime]);
return (
<>
<div
ref={contentRef}
className="w-full h-min m-2 grid sm:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4 gap-2 md:gap-4 overflow-auto no-scrollbar"
>
{reviewCameras.map((camera) => {
let grow;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > 2) {
grow = "sm:col-span-2 aspect-wide";
} else if (aspectRatio < 1) {
grow = "md:row-span-2 md:h-full aspect-tall";
} else {
grow = "aspect-video";
}
return (
<div key={camera.name} className={`${grow}`}>
<DynamicVideoPlayer
camera={camera.name}
timeRange={timeRangeSegments.ranges[selectedRangeIdx]}
cameraPreviews={relevantPreviews || []}
previewOnly
onControllerReady={(controller) => {
videoPlayersRef.current[camera.name] = controller;
setPlayerReady(true);
}}
/>
</div>
);
})}
</div>
<div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
<EventReviewTimeline
segmentDuration={segmentDuration}
timestampSpread={15}
timelineStart={timeRangeSegments.end}
timelineEnd={timeRangeSegments.start}
showHandlebar
handlebarTime={currentTime}
setHandlebarTime={setCurrentTime}
events={reviewItems.all}
severityType="significant_motion"
contentRef={contentRef}
/>
</div>
</>
); );
} }

View File

@ -5,21 +5,21 @@ import EventReviewTimeline from "@/components/timeline/EventReviewTimeline";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Preview } from "@/types/preview"; import { Preview } from "@/types/preview";
import { ReviewSegment } from "@/types/review"; import { ReviewSegment } from "@/types/review";
import { getChunkedTimeRange } from "@/utils/timelineUtil"; import { getChunkedTimeDay } from "@/utils/timelineUtil";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { IoMdArrowRoundBack } from "react-icons/io"; import { IoMdArrowRoundBack } from "react-icons/io";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
type DesktopRecordingViewProps = { type RecordingViewProps = {
selectedReview: ReviewSegment; selectedReview: ReviewSegment;
reviewItems: ReviewSegment[]; reviewItems: ReviewSegment[];
relevantPreviews?: Preview[]; relevantPreviews?: Preview[];
}; };
export default function DesktopRecordingView({ export default function RecordingView({
selectedReview, selectedReview,
reviewItems, reviewItems,
relevantPreviews, relevantPreviews,
}: DesktopRecordingViewProps) { }: RecordingViewProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
@ -31,7 +31,7 @@ export default function DesktopRecordingView({
// timeline time // timeline time
const timeRange = useMemo( const timeRange = useMemo(
() => getChunkedTimeRange(selectedReview.start_time), () => getChunkedTimeDay(selectedReview.start_time),
[selectedReview], [selectedReview],
); );
const [selectedRangeIdx, setSelectedRangeIdx] = useState( const [selectedRangeIdx, setSelectedRangeIdx] = useState(