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 (
+
+ );
+}
+
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",
},
},
},