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