From a946a8f0993e1616562aa5bee5b4bf3f0541aea8 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 31 Dec 2023 07:35:15 -0600 Subject: [PATCH] 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 --- web/package-lock.json | 13 + web/package.json | 1 + web/src/components/card/HistoryCard.tsx | 63 ++-- .../player/PreviewThumbnailPlayer.tsx | 136 ++++++-- .../components/scrubber/ActivityScrubber.tsx | 59 ++-- web/src/components/ui/button.tsx | 1 + web/src/components/ui/drawer.tsx | 116 +++++++ web/src/hooks/use-overlay-state.tsx | 20 ++ web/src/pages/History.tsx | 205 +++++------- web/src/types/history.ts | 6 + web/src/utils/timelineUtil.tsx | 34 ++ web/src/views/history/HistoryCardView.tsx | 145 +++++++++ web/src/views/history/HistoryTimelineView.tsx | 300 ++++++++++++++++++ web/tailwind.config.js | 3 +- 14 files changed, 892 insertions(+), 210 deletions(-) create mode 100644 web/src/components/ui/drawer.tsx create mode 100644 web/src/hooks/use-overlay-state.tsx create mode 100644 web/src/views/history/HistoryCardView.tsx create mode 100644 web/src/views/history/HistoryTimelineView.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 7df832b3a..af1204746 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -46,6 +46,7 @@ "swr": "^2.2.4", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", + "vaul": "^0.8.0", "video.js": "^8.6.1", "videojs-playlist": "^5.1.0", "vis-timeline": "^7.7.3", @@ -7758,6 +7759,18 @@ "node": ">=10.12.0" } }, + "node_modules/vaul": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.8.0.tgz", + "integrity": "sha512-9nUU2jIObJvJZxeQU1oVr/syKo5XqbRoOMoTEt0hHlWify4QZFlqTh6QSN/yxoKzNrMeEQzxbc3XC/vkPLOIqw==", + "dependencies": { + "@radix-ui/react-dialog": "^1.0.4" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, "node_modules/video.js": { "version": "8.6.1", "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.6.1.tgz", diff --git a/web/package.json b/web/package.json index b1512b431..ed225d214 100644 --- a/web/package.json +++ b/web/package.json @@ -51,6 +51,7 @@ "swr": "^2.2.4", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", + "vaul": "^0.8.0", "video.js": "^8.6.1", "videojs-playlist": "^5.1.0", "vis-timeline": "^7.7.3", diff --git a/web/src/components/card/HistoryCard.tsx b/web/src/components/card/HistoryCard.tsx index 3cb151bf8..4c2b9ca9b 100644 --- a/web/src/components/card/HistoryCard.tsx +++ b/web/src/components/card/HistoryCard.tsx @@ -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} /> -
+ <>
-
+
{formatUnixTimestampToDateTime(timeline.time, { strftime_fmt: @@ -55,35 +57,38 @@ export default function HistoryCard({ date_style: "medium", })}
- { - e.stopPropagation(); +
-
+
{timeline.camera.replaceAll("_", " ")}
-
Activity:
- {Object.entries(timeline.entries).map(([_, entry]) => { - return ( -
- {getTimelineIcon(entry)} - {getTimelineItemDescription(entry)} -
- ); - })} -
+
+
Activity:
+ {Object.entries(timeline.entries).map(([_, entry], idx) => { + return ( +
+ {getTimelineIcon(entry)} + {getTimelineItemDescription(entry)} +
+ ); + })} +
+ ); } diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 871ff0e9e..f27d7a2fb 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -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(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 ( + onPlayback(true)} + onMouseLeave={() => onPlayback(false)} + > + + + ); +} - if (relevantPreview && !visible) { - content =
; +type PreviewContentProps = { + playerRef: React.MutableRefObject; + 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
; } else if (!relevantPreview) { if (isCurrentHour(startTs)) { - content = ( + return ( ); } else { - content = ( + return (
{ 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 ( - onPlayback(true)} - onMouseLeave={() => onPlayback(false)} - > - {content} - - ); } function isCurrentHour(timestamp: number) { diff --git a/web/src/components/scrubber/ActivityScrubber.tsx b/web/src/components/scrubber/ActivityScrubber.tsx index 31c9db6ed..9f43e98c5 100644 --- a/web/src/components/scrubber/ActivityScrubber.tsx +++ b/web/src/components/scrubber/ActivityScrubber.tsx @@ -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
; + return ( +
+
+
+ ); } export default ActivityScrubber; diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index de31d9031..e51f78f89 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -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", diff --git a/web/src/components/ui/drawer.tsx b/web/src/components/ui/drawer.tsx new file mode 100644 index 000000000..c17b0ccaa --- /dev/null +++ b/web/src/components/ui/drawer.tsx @@ -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) => ( + +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx new file mode 100644 index 000000000..ee4ccaeca --- /dev/null +++ b/web/src/hooks/use-overlay-state.tsx @@ -0,0 +1,20 @@ +import { useCallback } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +export default function useOverlayState(key: string) { + const location = useLocation(); + const navigate = useNavigate(); + const currentLocationState = location.state; + + const setOverlayStateValue = useCallback( + (value: string) => { + const newLocationState = { ...currentLocationState }; + newLocationState[key] = value; + navigate(location.pathname, { state: newLocationState }); + }, + [navigate] + ); + + const overlayStateValue = location.state && location.state[key]; + return [overlayStateValue, setOverlayStateValue]; +} diff --git a/web/src/pages/History.tsx b/web/src/pages/History.tsx index ec40a4db5..cc0bca56a 100644 --- a/web/src/pages/History.tsx +++ b/web/src/pages/History.tsx @@ -1,13 +1,10 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import useSWR from "swr"; import useSWRInfinite from "swr/infinite"; import { FrigateConfig } from "@/types/frigateConfig"; import Heading from "@/components/ui/heading"; import ActivityIndicator from "@/components/ui/activity-indicator"; -import HistoryCard from "@/components/card/HistoryCard"; -import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import axios from "axios"; -import TimelinePlayerCard from "@/components/card/TimelinePlayerCard"; import { getHourlyTimelineData } from "@/utils/historyUtil"; import { AlertDialog, @@ -21,6 +18,13 @@ import { } from "@/components/ui/alert-dialog"; import HistoryFilterPopover from "@/components/filter/HistoryFilterPopover"; import useApiFilter from "@/hooks/use-api-filter"; +import HistoryCardView from "@/views/history/HistoryCardView"; +import HistoryTimelineView from "@/views/history/HistoryTimelineView"; +import { Button } from "@/components/ui/button"; +import { IoMdArrowBack } from "react-icons/io"; +import useOverlayState from "@/hooks/use-overlay-state"; +import { useNavigate } from "react-router-dom"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; const API_LIMIT = 200; @@ -80,10 +84,24 @@ function History() { { revalidateOnFocus: false } ); - const [playback, setPlayback] = useState(); + const navigate = useNavigate(); + const [playback, setPlayback] = useState(); + const [viewingPlayback, setViewingPlayback] = useOverlayState("timeline"); + const setPlaybackState = useCallback( + (playback: TimelinePlayback | undefined) => { + if (playback == undefined) { + setPlayback(undefined); + navigate(-1); + } else { + setPlayback(playback); + setViewingPlayback(true); + } + }, + [navigate] + ); - const shouldAutoPlay = useMemo(() => { - return playback == undefined && window.innerWidth < 480; + const isMobile = useMemo(() => { + return window.innerWidth < 768; }, [playback]); const timelineCards: CardsData | never[] = useMemo(() => { @@ -100,26 +118,6 @@ function History() { const isDone = (timelinePages?.[timelinePages.length - 1]?.count ?? 0) < API_LIMIT; - // hooks for infinite scroll - const observer = useRef(); - const lastTimelineRef = useCallback( - (node: HTMLElement | null) => { - if (isValidating) return; - if (observer.current) observer.current.disconnect(); - try { - observer.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !isDone) { - setSize(size + 1); - } - }); - if (node) observer.current.observe(node); - } catch (e) { - // no op - } - }, - [size, setSize, isValidating, isDone] - ); - const [itemsToDelete, setItemsToDelete] = useState(null); const onDelete = useCallback( async (timeline: Card) => { @@ -161,11 +159,25 @@ function History() { return ( <>
- History - setHistoryFilter(filter)} - /> +
+ {viewingPlayback && ( + + )} + History +
+ {!playback && ( + setHistoryFilter(filter)} + /> + )}
- - setPlayback(undefined)} + { + setSize(size + 1); + }} + onDelete={onDelete} + onItemSelected={(item) => setPlaybackState(item)} + /> + setPlaybackState(undefined)} /> - -
- {Object.entries(timelineCards) - .reverse() - .map(([day, timelineDay], dayIdx) => { - return ( -
- - {formatUnixTimestampToDateTime(parseInt(day), { - strftime_fmt: "%A %b %d", - time_style: "medium", - date_style: "medium", - })} - - {Object.entries(timelineDay).map( - ([hour, timelineHour], hourIdx) => { - if (Object.values(timelineHour).length == 0) { - return
; - } - - const lastRow = - dayIdx == Object.values(timelineCards).length - 1 && - hourIdx == Object.values(timelineDay).length - 1; - const previewMap: { [key: string]: Preview | undefined } = - {}; - - return ( -
- - {formatUnixTimestampToDateTime(parseInt(hour), { - strftime_fmt: - config.ui.time_format == "24hour" - ? "%H:00" - : "%I:00 %p", - time_style: "medium", - date_style: "medium", - })} - - -
- {Object.entries(timelineHour) - .reverse() - .map(([key, timeline]) => { - const startTs = Object.values(timeline.entries)[0] - .timestamp; - let relevantPreview = previewMap[timeline.camera]; - - if (relevantPreview == undefined) { - relevantPreview = previewMap[timeline.camera] = - Object.values(allPreviews || []).find( - (preview) => - preview.camera == timeline.camera && - preview.start < startTs && - preview.end > startTs - ); - } - - return ( - { - setPlayback(timeline); - }} - onDelete={() => onDelete(timeline)} - /> - ); - })} -
- {lastRow && !isDone && } -
- ); - } - )} -
- ); - })} -
); } +type TimelineViewerProps = { + playback: TimelinePlayback | undefined; + isMobile: boolean; + onClose: () => void; +}; + +function TimelineViewer({ playback, isMobile, onClose }: TimelineViewerProps) { + if (isMobile) { + return playback != undefined ? ( +
+ +
+ ) : null; + } + + return ( + onClose()}> + + {playback && ( + + )} + + + ); +} + export default History; diff --git a/web/src/types/history.ts b/web/src/types/history.ts index a6681dac9..e9b3f57c1 100644 --- a/web/src/types/history.ts +++ b/web/src/types/history.ts @@ -55,3 +55,9 @@ interface HistoryFilter extends FilterType { after: number | undefined; detailLevel: "normal" | "extra" | "full"; } + +type TimelinePlayback = { + camera: string; + timelineItems: Timeline[]; + relevantPreview: Preview | undefined; +}; diff --git a/web/src/utils/timelineUtil.tsx b/web/src/utils/timelineUtil.tsx index 5f9333291..1820df5f5 100644 --- a/web/src/utils/timelineUtil.tsx +++ b/web/src/utils/timelineUtil.tsx @@ -1,17 +1,25 @@ import { + LuCamera, + LuCar, + LuCat, LuCircle, LuCircleDot, + LuDog, LuEar, + LuPackage, + LuPersonStanding, LuPlay, LuPlayCircle, LuTruck, } from "react-icons/lu"; +import { GiDeer } from "react-icons/gi"; import { IoMdExit } from "react-icons/io"; import { MdFaceUnlock, MdOutlineLocationOn, MdOutlinePictureInPictureAlt, } from "react-icons/md"; +import { FaBicycle } from "react-icons/fa"; export function getTimelineIcon(timelineItem: Timeline) { switch (timelineItem.class_type) { @@ -50,6 +58,32 @@ export function getTimelineIcon(timelineItem: Timeline) { } } +/** + * Get icon representing detection, either label specific or generic detection icon + * @param timelineItem timeline item + * @returns icon for label + */ +export function getTimelineDetectionIcon(timelineItem: Timeline) { + switch (timelineItem.data.label) { + case "bicycle": + return ; + case "car": + return ; + case "cat": + return ; + case "deer": + return ; + case "dog": + return ; + case "package": + return ; + case "person": + return ; + default: + return ; + } +} + export function getTimelineItemDescription(timelineItem: Timeline) { const label = ( (Array.isArray(timelineItem.data.sub_label) diff --git a/web/src/views/history/HistoryCardView.tsx b/web/src/views/history/HistoryCardView.tsx new file mode 100644 index 000000000..eb28ca238 --- /dev/null +++ b/web/src/views/history/HistoryCardView.tsx @@ -0,0 +1,145 @@ +import HistoryCard from "@/components/card/HistoryCard"; +import ActivityIndicator from "@/components/ui/activity-indicator"; +import Heading from "@/components/ui/heading"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import { useCallback, useRef } from "react"; +import useSWR from "swr"; + +type HistoryCardViewProps = { + timelineCards: CardsData | never[]; + allPreviews: Preview[] | undefined; + isMobile: boolean; + isValidating: boolean; + isDone: boolean; + onNextPage: () => void; + onDelete: (card: Card) => void; + onItemSelected: (item: TimelinePlayback) => void; +}; + +export default function HistoryCardView({ + timelineCards, + allPreviews, + isMobile, + isValidating, + isDone, + onNextPage, + onDelete, + onItemSelected, +}: HistoryCardViewProps) { + const { data: config } = useSWR("config"); + + // hooks for infinite scroll + const observer = useRef(); + const lastTimelineRef = useCallback( + (node: HTMLElement | null) => { + if (isValidating) return; + if (observer.current) observer.current.disconnect(); + try { + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !isDone) { + onNextPage(); + } + }); + if (node) observer.current.observe(node); + } catch (e) { + // no op + } + }, + [isValidating, isDone] + ); + + return ( + <> + {Object.entries(timelineCards) + .reverse() + .map(([day, timelineDay], dayIdx) => { + return ( +
+ + {formatUnixTimestampToDateTime(parseInt(day), { + strftime_fmt: "%A %b %d", + time_style: "medium", + date_style: "medium", + })} + + {Object.entries(timelineDay).map( + ([hour, timelineHour], hourIdx) => { + if (Object.values(timelineHour).length == 0) { + return
; + } + + const lastRow = + dayIdx == Object.values(timelineCards).length - 1 && + hourIdx == Object.values(timelineDay).length - 1; + const previewMap: { [key: string]: Preview | undefined } = {}; + + return ( +
+ + {formatUnixTimestampToDateTime(parseInt(hour), { + strftime_fmt: + config?.ui.time_format == "24hour" + ? "%H:00" + : "%I:00 %p", + time_style: "medium", + date_style: "medium", + })} + + +
+ {Object.entries(timelineHour) + .reverse() + .map(([key, timeline]) => { + const startTs = Object.values(timeline.entries)[0] + .timestamp; + let relevantPreview = previewMap[timeline.camera]; + + if (relevantPreview == undefined) { + relevantPreview = previewMap[timeline.camera] = + Object.values(allPreviews || []).find( + (preview) => + preview.camera == timeline.camera && + preview.start < startTs && + preview.end > startTs + ); + } + + return ( + { + onItemSelected({ + camera: timeline.camera, + timelineItems: Object.values( + timelineHour + ).flatMap((card) => + card.camera == timeline.camera + ? card.entries + : [] + ), + relevantPreview: relevantPreview, + }); + }} + onDelete={() => onDelete(timeline)} + /> + ); + })} +
+ {lastRow && !isDone && } +
+ ); + } + )} +
+ ); + })} + + ); +} diff --git a/web/src/views/history/HistoryTimelineView.tsx b/web/src/views/history/HistoryTimelineView.tsx new file mode 100644 index 000000000..a892f8e9d --- /dev/null +++ b/web/src/views/history/HistoryTimelineView.tsx @@ -0,0 +1,300 @@ +import { useApiHost } from "@/api"; +import TimelineEventOverlay from "@/components/overlay/TimelineDataOverlay"; +import VideoPlayer from "@/components/player/VideoPlayer"; +import ActivityScrubber, { + ScrubberItem, +} from "@/components/scrubber/ActivityScrubber"; +import ActivityIndicator from "@/components/ui/activity-indicator"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { + getTimelineDetectionIcon, + getTimelineIcon, +} from "@/utils/timelineUtil"; +import { renderToStaticMarkup } from "react-dom/server"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import useSWR from "swr"; +import Player from "video.js/dist/types/player"; + +type HistoryTimelineViewProps = { + playback: TimelinePlayback; + isMobile: boolean; +}; + +export default function HistoryTimelineView({ + playback, + isMobile, +}: HistoryTimelineViewProps) { + const apiHost = useApiHost(); + const { data: config } = useSWR("config"); + const timezone = useMemo( + () => + config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + [config] + ); + + const hasRelevantPreview = playback.relevantPreview != undefined; + + const playerRef = useRef(undefined); + const previewRef = useRef(undefined); + + const [scrubbing, setScrubbing] = useState(false); + const [focusedItem, setFocusedItem] = useState( + undefined + ); + + const [seeking, setSeeking] = useState(false); + const [timeToSeek, setTimeToSeek] = useState(undefined); + + const annotationOffset = useMemo(() => { + if (!config) { + return 0; + } + + return ( + (config.cameras[playback.camera]?.detect?.annotation_offset || 0) / 1000 + ); + }, [config, playback]); + + const timelineTime = useMemo(() => { + if (!playback) { + return 0; + } + + return playback.timelineItems.at(0)!!.timestamp; + }, [playback]); + const playbackTimes = useMemo(() => { + const date = new Date(timelineTime * 1000); + date.setMinutes(0, 0, 0); + const startTime = date.getTime() / 1000; + date.setHours(date.getHours() + 1); + const endTime = date.getTime() / 1000; + return { + start: parseInt(startTime.toFixed(1)), + end: parseInt(endTime.toFixed(1)), + }; + }, [timelineTime]); + + const recordingParams = useMemo(() => { + return { + before: playbackTimes.end, + after: playbackTimes.start, + }; + }, [playbackTimes]); + const { data: recordings } = useSWR( + playback ? [`${playback.camera}/recordings`, recordingParams] : null, + { revalidateOnFocus: false } + ); + + const playbackUri = useMemo(() => { + if (!playback) { + return ""; + } + + const date = new Date(playbackTimes.start * 1000); + return `${apiHost}vod/${date.getFullYear()}-${ + date.getMonth() + 1 + }/${date.getDate()}/${date.getHours()}/${ + playback.camera + }/${timezone.replaceAll("/", ",")}/master.m3u8`; + }, [playbackTimes]); + + const onSelectItem = useCallback( + (data: { items: number[] }) => { + if (data.items.length > 0) { + const selected = data.items[0]; + setFocusedItem( + playback.timelineItems.find( + (timeline) => timeline.timestamp == selected + ) + ); + playerRef.current?.pause(); + + let seekSeconds = 0; + (recordings || []).every((segment) => { + // if the next segment is past the desired time, stop calculating + if (segment.start_time > selected) { + return false; + } + + if (segment.end_time < selected) { + seekSeconds += segment.end_time - segment.start_time; + return true; + } + + seekSeconds += + segment.end_time - + segment.start_time - + (segment.end_time - selected); + return true; + }); + playerRef.current?.currentTime(seekSeconds); + } + }, + [annotationOffset, recordings, playerRef] + ); + + const onScrubTime = useCallback( + (data: { time: Date }) => { + if (!hasRelevantPreview) { + return; + } + + if (playerRef.current?.paused() == false) { + setScrubbing(true); + playerRef.current?.pause(); + } + + const seekTimestamp = data.time.getTime() / 1000; + const seekTime = seekTimestamp - playback.relevantPreview!!.start; + setTimeToSeek(Math.round(seekTime)); + }, + [scrubbing, playerRef] + ); + + const onStopScrubbing = useCallback( + (data: { time: Date }) => { + const playbackTime = data.time.getTime() / 1000; + playerRef.current?.currentTime(playbackTime - playbackTimes.start); + setScrubbing(false); + playerRef.current?.play(); + }, + [playerRef] + ); + + // handle seeking to next frame when seek is finished + useEffect(() => { + if (seeking) { + return; + } + + if (timeToSeek && timeToSeek != previewRef.current?.currentTime()) { + setSeeking(true); + previewRef.current?.currentTime(timeToSeek); + } + }, [timeToSeek, seeking]); + + if (!config || !recordings) { + return ; + } + + return ( +
+ <> +
+ { + playerRef.current = player; + player.currentTime(timelineTime - playbackTimes.start); + player.on("playing", () => { + setFocusedItem(undefined); + }); + }} + onDispose={() => { + playerRef.current = undefined; + }} + > + {config && focusedItem ? ( + + ) : undefined} + +
+ {hasRelevantPreview && ( +
+ { + previewRef.current = player; + player.on("seeked", () => setSeeking(false)); + }} + onDispose={() => { + previewRef.current = undefined; + }} + /> +
+ )} + +
+ {playback != undefined && ( + + )} +
+
+ ); +} + +function timelineItemsToScrubber(items: Timeline[]): ScrubberItem[] { + return items.map((item) => { + return { + id: item.timestamp, + content: getTimelineContentElement(item), + start: new Date(item.timestamp * 1000), + end: new Date(item.timestamp * 1000), + type: "box", + }; + }); +} + +function getTimelineContentElement(item: Timeline): HTMLElement { + const output = document.createElement(`div-${item.timestamp}`); + output.innerHTML = renderToStaticMarkup( +
+ {getTimelineDetectionIcon(item)} : {getTimelineIcon(item)} +
+ ); + return output; +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index e6180eacd..c52bb7959 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -72,7 +72,8 @@ module.exports = { }, screens: { "xs": "480px", - "2xl": "1400px", + "2xl": "1440px", + "3xl": "1920px", }, }, },