diff --git a/web/src/components/scrubber/ActivityScrubber.tsx b/web/src/components/scrubber/ActivityScrubber.tsx
index 31c9db6ed..229a4d3ac 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";
@@ -73,12 +75,15 @@ const domEvents: TimelineEventsWithMissing[] = [
type ActivityScrubberProps = {
items: TimelineItem[];
+ midBar: boolean;
+ timeBars: { time: DateType; id?: IdType | undefined }[];
groups?: TimelineGroup[];
options?: TimelineOptions;
} & TimelineEventsHandlers;
function ActivityScrubber({
items,
+ timeBars,
groups,
options,
...eventHandlers
@@ -130,6 +135,12 @@ function ActivityScrubber({
options
);
+ if (timeBars) {
+ timeBars.forEach((bar) => {
+ timelineInstance.addCustomTime(bar.time, bar.id);
+ });
+ }
+
domEvents.forEach((event) => {
const eventHandler = eventHandlers[`${event}Handler`];
if (typeof eventHandler === "function") {
@@ -174,7 +185,12 @@ function ActivityScrubber({
if (items) timelineRef.current.timeline.setItems(items);
}, [items, groups, options, currentTime, eventHandlers]);
- return
;
+ return (
+
+ );
}
export default ActivityScrubber;
diff --git a/web/src/pages/History.tsx b/web/src/pages/History.tsx
index ec40a4db5..8ec118fe3 100644
--- a/web/src/pages/History.tsx
+++ b/web/src/pages/History.tsx
@@ -1,11 +1,9 @@
-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";
@@ -21,6 +19,8 @@ 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";
const API_LIMIT = 200;
@@ -100,26 +100,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) => {
@@ -162,10 +142,12 @@ function History() {
<>
History
- setHistoryFilter(filter)}
- />
+ {!playback && (
+ setHistoryFilter(filter)}
+ />
+ )}
setPlayback(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 &&
}
-
- );
- }
- )}
-
- );
- })}
-
+ <>
+ {playback == undefined && (
+ {
+ setSize(size + 1);
+ }}
+ onDelete={onDelete}
+ onItemSelected={(card) => setPlayback(card)}
+ />
+ )}
+ {playback != undefined && }
+ >
>
);
}
diff --git a/web/src/views/history/HistoryCardView.tsx b/web/src/views/history/HistoryCardView.tsx
new file mode 100644
index 000000000..9153c0ca9
--- /dev/null
+++ b/web/src/views/history/HistoryCardView.tsx
@@ -0,0 +1,135 @@
+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;
+ isMobileView: boolean;
+ isValidating: boolean;
+ isDone: boolean;
+ onNextPage: () => void;
+ onDelete: (card: Card) => void;
+ onItemSelected: (card: Card) => void;
+};
+
+export default function HistoryCardView({
+ timelineCards,
+ allPreviews,
+ isMobileView,
+ 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(timeline);
+ }}
+ 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..710cd55d8
--- /dev/null
+++ b/web/src/views/history/HistoryTimelineView.tsx
@@ -0,0 +1,148 @@
+import { useApiHost } from "@/api";
+import VideoPlayer from "@/components/player/VideoPlayer";
+import ActivityScrubber from "@/components/scrubber/ActivityScrubber";
+import {
+ getTimelineIcon,
+ getTimelineItemDescription,
+} from "@/utils/timelineUtil";
+import { useMemo, useRef, useState } from "react";
+import { LuDog } from "react-icons/lu";
+import Player from "video.js/dist/types/player";
+
+type HistoryTimelineViewProps = {
+ card: Card;
+};
+
+export default function HistoryTimelineView({
+ card,
+}: HistoryTimelineViewProps) {
+ const apiHost = useApiHost();
+ const playerRef = useRef(undefined);
+ const previewRef = useRef(undefined);
+
+ const [scrubbing, setScrubbing] = useState(false);
+ const relevantPreview = {
+ src: "http://localhost:5173/clips/previews/side_cam/1703174400.071426-1703178000.011979.mp4",
+ start: 1703174400.071426,
+ end: 1703178000.011979,
+ };
+
+ const timelineTime = useMemo(() => card.entries.at(0)!!.timestamp, [card]);
+ 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: startTime.toFixed(1), end: endTime.toFixed(1) };
+ }, [timelineTime]);
+
+ const playbackUri = useMemo(() => {
+ if (!card) {
+ return "";
+ }
+
+ return `${apiHost}vod/${card?.camera}/start/${playbackTimes.start}/end/${playbackTimes.end}/master.m3u8`;
+ }, [card, playbackTimes]);
+
+ return (
+ <>
+
+
+
+ {
+ playerRef.current = player;
+ player.currentTime(
+ timelineTime - parseInt(playbackTimes.start)
+ );
+ player.on("playing", () => {
+ //setSelectedItem(undefined);
+ });
+ }}
+ onDispose={() => {
+ playerRef.current = undefined;
+ }}
+ />
+
+
+ {
+ previewRef.current = player;
+ }}
+ onDispose={() => {
+ previewRef.current = undefined;
+ }}
+ />
+
+
{
+ if (!scrubbing) {
+ playerRef.current?.pause();
+ setScrubbing(true);
+ }
+
+ const rangeStart = time.start.getTime() / 1000;
+ const midTime =
+ rangeStart + (time.end.getTime() / 1000 - rangeStart / 2);
+ previewRef.current?.currentTime(midTime - relevantPreview.start);
+ }}
+ rangechangedHandler={(data) => {
+ const playbackTime = data.time.getTime() / 1000;
+ playerRef.current?.currentTime(
+ playbackTime - parseInt(playbackTimes.start)
+ );
+ setScrubbing(false);
+ playerRef.current?.play();
+ }}
+ />
+
+
+ >
+ );
+}
+
+function timelineItemsToScrubber(items: Timeline[]) {
+ return items.map((item) => {
+ return {
+ id: item.timestamp,
+ content: `${getTimelineItemDescription(
+ item
+ )}
`,
+ start: new Date(item.timestamp * 1000),
+ end: new Date(item.timestamp * 1000),
+ type: "box",
+ };
+ });
+}