Refactor history viewer to show player / timeline for full hour and use preview while scrubbing timeline (#9051)

* Move history card view to separate view and create timeline view

* Get custom time scrubber working

* Add back nav

* Show timeline bounding boxes

* Implement seeking limiter

* Use browser history to allow back button to close timeline viewer

* Fix mobile timeline and add more icons for detections

* Play when item is initially visible
This commit is contained in:
Nicolas Mowen
2023-12-31 07:35:15 -06:00
committed by Blake Blackshear
parent 9a0dfa723a
commit a946a8f099
14 changed files with 892 additions and 210 deletions

View File

@@ -10,11 +10,12 @@ import {
getTimelineIcon,
getTimelineItemDescription,
} from "@/utils/timelineUtil";
import { Button } from "../ui/button";
type HistoryCardProps = {
timeline: Card;
relevantPreview?: Preview;
shouldAutoPlay: boolean;
isMobile: boolean;
onClick?: () => void;
onDelete?: () => void;
};
@@ -22,7 +23,7 @@ type HistoryCardProps = {
export default function HistoryCard({
relevantPreview,
timeline,
shouldAutoPlay,
isMobile,
onClick,
onDelete,
}: HistoryCardProps) {
@@ -42,11 +43,12 @@ export default function HistoryCard({
relevantPreview={relevantPreview}
startTs={Object.values(timeline.entries)[0].timestamp}
eventId={Object.values(timeline.entries)[0].source_id}
shouldAutoPlay={shouldAutoPlay}
isMobile={isMobile}
onClick={onClick}
/>
<div className="p-2">
<>
<div className="text-sm flex justify-between items-center">
<div>
<div className="pl-1 pt-1">
<LuClock className="h-5 w-5 mr-2 inline" />
{formatUnixTimestampToDateTime(timeline.time, {
strftime_fmt:
@@ -55,35 +57,38 @@ export default function HistoryCard({
date_style: "medium",
})}
</div>
<LuTrash
className="w-5 h-5 m-1 cursor-pointer"
stroke="#f87171"
onClick={(e: Event) => {
e.stopPropagation();
<Button className="px-2 py-2" variant="ghost" size="xs">
<LuTrash
className="w-5 h-5 stroke-red-500"
onClick={(e: Event) => {
e.stopPropagation();
if (onDelete) {
onDelete();
}
}}
/>
if (onDelete) {
onDelete();
}
}}
/>
</Button>
</div>
<div className="capitalize text-sm flex items-center mt-1">
<div className="pl-1 capitalize text-sm flex items-center mt-1">
<HiOutlineVideoCamera className="h-5 w-5 mr-2 inline" />
{timeline.camera.replaceAll("_", " ")}
</div>
<div className="my-2 text-sm font-medium">Activity:</div>
{Object.entries(timeline.entries).map(([_, entry]) => {
return (
<div
key={entry.timestamp}
className="flex text-xs capitalize my-1 items-center"
>
{getTimelineIcon(entry)}
{getTimelineItemDescription(entry)}
</div>
);
})}
</div>
<div className="pl-1 my-2">
<div className="text-sm font-medium">Activity:</div>
{Object.entries(timeline.entries).map(([_, entry], idx) => {
return (
<div
key={idx}
className="flex text-xs capitalize my-1 items-center"
>
{getTimelineIcon(entry)}
{getTimelineItemDescription(entry)}
</div>
);
})}
</div>
</>
</Card>
);
}

View File

@@ -1,7 +1,13 @@
import { FrigateConfig } from "@/types/frigateConfig";
import VideoPlayer from "./VideoPlayer";
import useSWR from "swr";
import { useCallback, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useApiHost } from "@/api";
import Player from "video.js/dist/types/player";
import { AspectRatio } from "../ui/aspect-ratio";
@@ -12,7 +18,8 @@ type PreviewPlayerProps = {
relevantPreview?: Preview;
startTs: number;
eventId: string;
shouldAutoPlay: boolean;
isMobile: boolean;
onClick?: () => void;
};
type Preview = {
@@ -28,20 +35,26 @@ export default function PreviewThumbnailPlayer({
relevantPreview,
startTs,
eventId,
shouldAutoPlay,
isMobile,
onClick,
}: PreviewPlayerProps) {
const { data: config } = useSWR("config");
const playerRef = useRef<Player | null>(null);
const apiHost = useApiHost();
const isSafari = useMemo(() => {
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
}, []);
const [visible, setVisible] = useState(false);
const [isInitiallyVisible, setIsInitiallyVisible] = useState(false);
const onPlayback = useCallback(
(isHovered: Boolean) => {
if (!relevantPreview || !playerRef.current) {
if (!relevantPreview) {
return;
}
if (!playerRef.current) {
setIsInitiallyVisible(true);
return;
}
@@ -78,7 +91,7 @@ export default function PreviewThumbnailPlayer({
}
}
if (shouldAutoPlay && !autoPlayObserver.current) {
if (isMobile && !autoPlayObserver.current) {
try {
autoPlayObserver.current = new IntersectionObserver(
(entries) => {
@@ -92,8 +105,6 @@ export default function PreviewThumbnailPlayer({
{
threshold: 1.0,
root: document.getElementById("pageRoot"),
// iOS has bug where poster is empty frame until video starts playing so playback needs to begin earlier
rootMargin: isSafari ? "10% 0px 25% 0px" : "0px",
}
);
if (node) autoPlayObserver.current.observe(node);
@@ -105,20 +116,95 @@ export default function PreviewThumbnailPlayer({
[preloadObserver, autoPlayObserver, onPlayback]
);
let content;
return (
<AspectRatio
ref={relevantPreview ? inViewRef : null}
ratio={16 / 9}
className="bg-black flex justify-center items-center"
onMouseEnter={() => onPlayback(true)}
onMouseLeave={() => onPlayback(false)}
>
<PreviewContent
playerRef={playerRef}
relevantPreview={relevantPreview}
isVisible={visible}
isInitiallyVisible={isInitiallyVisible}
startTs={startTs}
camera={camera}
config={config}
eventId={eventId}
isMobile={isMobile}
isSafari={isSafari}
onClick={onClick}
/>
</AspectRatio>
);
}
if (relevantPreview && !visible) {
content = <div />;
type PreviewContentProps = {
playerRef: React.MutableRefObject<Player | null>;
config: FrigateConfig;
camera: string;
relevantPreview: Preview | undefined;
eventId: string;
isVisible: boolean;
isInitiallyVisible: boolean;
startTs: number;
isMobile: boolean;
isSafari: boolean;
onClick?: () => void;
};
function PreviewContent({
playerRef,
config,
camera,
relevantPreview,
eventId,
isVisible,
isInitiallyVisible,
startTs,
isMobile,
isSafari,
onClick,
}: PreviewContentProps) {
const apiHost = useApiHost();
// handle touchstart -> touchend as click
const [touchStart, setTouchStart] = useState(0);
const handleTouchStart = useCallback(() => {
setTouchStart(new Date().getTime());
}, []);
useEffect(() => {
if (!isMobile || !playerRef.current || !onClick) {
return;
}
playerRef.current.on("touchend", () => {
if (!onClick) {
return;
}
const touchEnd = new Date().getTime();
// consider tap less than 500 ms
if (touchEnd - touchStart < 500) {
onClick();
}
});
}, [playerRef, touchStart]);
if (relevantPreview && !isVisible) {
return <div />;
} else if (!relevantPreview) {
if (isCurrentHour(startTs)) {
content = (
return (
<img
className={`${getPreviewWidth(camera, config)}`}
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
/>
);
} else {
content = (
return (
<img
className="w-[160px]"
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
@@ -126,13 +212,13 @@ export default function PreviewThumbnailPlayer({
);
}
} else {
content = (
return (
<>
<div className={`${getPreviewWidth(camera, config)}`}>
<VideoPlayer
options={{
preload: "auto",
autoplay: false,
autoplay: true,
controls: false,
muted: true,
loadingSpinner: false,
@@ -146,8 +232,16 @@ export default function PreviewThumbnailPlayer({
seekOptions={{}}
onReady={(player) => {
playerRef.current = player;
if (!isInitiallyVisible) {
player.pause(); // autoplay + pause is required for iOS
}
player.playbackRate(isSafari ? 2 : 8);
player.currentTime(startTs - relevantPreview.start);
if (isMobile && onClick) {
player.on("touchstart", handleTouchStart);
}
}}
onDispose={() => {
playerRef.current = null;
@@ -158,18 +252,6 @@ export default function PreviewThumbnailPlayer({
</>
);
}
return (
<AspectRatio
ref={relevantPreview ? inViewRef : null}
ratio={16 / 9}
className="bg-black flex justify-center items-center"
onMouseEnter={() => onPlayback(true)}
onMouseLeave={() => onPlayback(false)}
>
{content}
</AspectRatio>
);
}
function isCurrentHour(timestamp: number) {

View File

@@ -4,6 +4,8 @@ import {
TimelineGroup,
TimelineItem,
TimelineOptions,
DateType,
IdType,
} from "vis-timeline";
import type { DataGroup, DataItem, TimelineEvents } from "vis-timeline/types";
import "./scrubber.css";
@@ -72,13 +74,17 @@ const domEvents: TimelineEventsWithMissing[] = [
];
type ActivityScrubberProps = {
items: TimelineItem[];
className?: string;
items?: TimelineItem[];
timeBars?: { time: DateType; id?: IdType | undefined }[];
groups?: TimelineGroup[];
options?: TimelineOptions;
} & TimelineEventsHandlers;
function ActivityScrubber({
className,
items,
timeBars,
groups,
options,
...eventHandlers
@@ -123,13 +129,24 @@ function ActivityScrubber({
return;
}
const timelineOptions: TimelineOptions = {
...defaultOptions,
...options,
};
const timelineInstance = new VisTimeline(
divElement,
items as DataItem[],
groups as DataGroup[],
options
timelineOptions
);
if (timeBars) {
timeBars.forEach((bar) => {
timelineInstance.addCustomTime(bar.time, bar.id);
});
}
domEvents.forEach((event) => {
const eventHandler = eventHandlers[`${event}Handler`];
if (typeof eventHandler === "function") {
@@ -139,42 +156,16 @@ function ActivityScrubber({
timelineRef.current.timeline = timelineInstance;
const timelineOptions: TimelineOptions = {
...defaultOptions,
...options,
};
timelineInstance.setOptions(timelineOptions);
return () => {
timelineInstance.destroy();
};
}, []);
}, [containerRef]);
useEffect(() => {
if (!timelineRef.current.timeline) {
return;
}
// If the currentTime updates, adjust the scrubber's end date and max
// May not be applicable to all scrubbers, might want to just pass this in
// for any scrubbers that we want to dynamically move based on time
// const updatedTimeOptions: TimelineOptions = {
// end: currentTime,
// max: currentTime,
// };
const timelineOptions: TimelineOptions = {
...defaultOptions,
// ...updatedTimeOptions,
...options,
};
timelineRef.current.timeline.setOptions(timelineOptions);
if (items) timelineRef.current.timeline.setItems(items);
}, [items, groups, options, currentTime, eventHandlers]);
return <div ref={containerRef} />;
return (
<div className={className || ""}>
<div ref={containerRef} />
</div>
);
}
export default ActivityScrubber;

View File

@@ -21,6 +21,7 @@ const buttonVariants = cva(
},
size: {
default: "h-10 px-4 py-2",
xs: "h-6 rounded-md",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}