forked from Github/frigate
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:
committed by
Blake Blackshear
parent
9a0dfa723a
commit
a946a8f099
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
116
web/src/components/ui/drawer.tsx
Normal file
116
web/src/components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user