From 51011dcd871e326d0a68e49d73d8e8d2be3aec28 Mon Sep 17 00:00:00 2001 From: Nick Mowen Date: Sun, 31 Dec 2023 08:22:54 -0700 Subject: [PATCH] Add full day of timelines --- web/src/pages/History.tsx | 31 +++- web/src/types/history.ts | 7 +- web/src/utils/dateUtil.ts | 13 +- web/src/utils/historyUtil.ts | 50 +++++- web/src/views/history/HistoryCardView.tsx | 6 +- web/src/views/history/HistoryTimelineView.tsx | 170 ++++++++++-------- 6 files changed, 181 insertions(+), 96 deletions(-) diff --git a/web/src/pages/History.tsx b/web/src/pages/History.tsx index 9a2398413..b8ad364d1 100644 --- a/web/src/pages/History.tsx +++ b/web/src/pages/History.tsx @@ -104,9 +104,9 @@ function History() { return window.innerWidth < 768; }, [playback]); - const timelineCards: CardsData | never[] = useMemo(() => { + const timelineCards: CardsData = useMemo(() => { if (!timelinePages) { - return []; + return {}; } return getHourlyTimelineData( @@ -152,7 +152,7 @@ function History() { } }, [itemsToDelete, updateHistory]); - if (!config || !timelineCards || timelineCards.length == 0) { + if (!config || !timelineCards) { return ; } @@ -217,6 +217,7 @@ function History() { onItemSelected={(item) => setPlaybackState(item)} /> setPlaybackState(undefined)} @@ -226,16 +227,28 @@ function History() { } type TimelineViewerProps = { + timelineData: CardsData | undefined; playback: TimelinePlayback | undefined; isMobile: boolean; onClose: () => void; }; -function TimelineViewer({ playback, isMobile, onClose }: TimelineViewerProps) { +function TimelineViewer({ + timelineData, + playback, + isMobile, + onClose, +}: TimelineViewerProps) { if (isMobile) { return playback != undefined ? (
- + {timelineData && ( + + )}
) : null; } @@ -243,8 +256,12 @@ function TimelineViewer({ playback, isMobile, onClose }: TimelineViewerProps) { return ( onClose()}> - {playback && ( - + {timelineData && playback && ( + )} diff --git a/web/src/types/history.ts b/web/src/types/history.ts index e9b3f57c1..f54344871 100644 --- a/web/src/types/history.ts +++ b/web/src/types/history.ts @@ -1,7 +1,7 @@ type CardsData = { - [key: string]: { - [key: string]: { - [key: string]: Card; + [day: string]: { + [hour: string]: { + [groupKey: string]: Card; }; }; }; @@ -58,6 +58,7 @@ interface HistoryFilter extends FilterType { type TimelinePlayback = { camera: string; + range: { start: number; end: number }; timelineItems: Timeline[]; relevantPreview: Preview | undefined; }; diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts index 1c6db110c..6f955d6dd 100644 --- a/web/src/utils/dateUtil.ts +++ b/web/src/utils/dateUtil.ts @@ -1,5 +1,5 @@ -import strftime from 'strftime'; -import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns'; +import strftime from "strftime"; +import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns"; export const longToDate = (long: number): Date => new Date(long * 1000); export const epochToLong = (date: number): number => date / 1000; export const dateToLong = (date: Date): number => epochToLong(date.getTime()); @@ -276,3 +276,12 @@ const getUTCOffset = (date: Date, timezone: string): number => { return (target.getTime() - utcDate.getTime()) / 60 / 1000; }; + +export function getRangeForTimestamp(timestamp: number) { + const date = new Date(timestamp * 1000); + date.setMinutes(0, 0, 0); + const start = date.getTime() / 1000; + date.setHours(date.getHours() + 1); + const end = date.getTime() / 1000; + return { start, end }; +} diff --git a/web/src/utils/historyUtil.ts b/web/src/utils/historyUtil.ts index ec2145789..838ff42d9 100644 --- a/web/src/utils/historyUtil.ts +++ b/web/src/utils/historyUtil.ts @@ -4,7 +4,7 @@ const GROUP_SECONDS = 60; export function getHourlyTimelineData( timelinePages: HourlyTimeline[], detailLevel: string -) { +): CardsData { const cards: CardsData = {}; timelinePages.forEach((hourlyTimeline) => { Object.keys(hourlyTimeline["hours"]) @@ -102,14 +102,32 @@ export function getHourlyTimelineData( return cards; } -export function getTimelineHoursForDay(timestamp: number) { +export function getTimelineHoursForDay( + camera: string, + cards: CardsData, + timestamp: number +): TimelinePlayback[] { const now = new Date(); - const data = []; + const data: TimelinePlayback[] = []; const startDay = new Date(timestamp * 1000); startDay.setHours(0, 0, 0, 0); let start = startDay.getTime() / 1000; let end = 0; + const dayIdx = Object.keys(cards).find((day) => { + if (parseInt(day) > start) { + return false; + } + + return true; + }); + + if (dayIdx == undefined) { + return []; + } + + const day = cards[dayIdx]; + for (let i = 0; i < 24; i++) { startDay.setHours(startDay.getHours() + 1); @@ -118,7 +136,31 @@ export function getTimelineHoursForDay(timestamp: number) { } end = startDay.getTime() / 1000; - data.push({ start, end }); + const hour = Object.values(day).find((cards) => { + if ( + Object.values(cards)[0].time < start || + Object.values(cards)[0].time > end + ) { + return false; + } + + return true; + }); + const timelineItems: Timeline[] = hour + ? Object.values(hour).flatMap((card) => { + if (card.camera == camera) { + return card.entries; + } + + return []; + }) + : []; + data.push({ + camera, + range: { start, end }, + timelineItems, + relevantPreview: undefined, + }); start = startDay.getTime() / 1000; } diff --git a/web/src/views/history/HistoryCardView.tsx b/web/src/views/history/HistoryCardView.tsx index eb28ca238..36238a43f 100644 --- a/web/src/views/history/HistoryCardView.tsx +++ b/web/src/views/history/HistoryCardView.tsx @@ -2,7 +2,10 @@ 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 { + formatUnixTimestampToDateTime, + getRangeForTimestamp, +} from "@/utils/dateUtil"; import { useCallback, useRef } from "react"; import useSWR from "swr"; @@ -117,6 +120,7 @@ export default function HistoryCardView({ onClick={() => { onItemSelected({ camera: timeline.camera, + range: getRangeForTimestamp(timeline.time), timelineItems: Object.values( timelineHour ).flatMap((card) => diff --git a/web/src/views/history/HistoryTimelineView.tsx b/web/src/views/history/HistoryTimelineView.tsx index cd821430c..9bbd17d30 100644 --- a/web/src/views/history/HistoryTimelineView.tsx +++ b/web/src/views/history/HistoryTimelineView.tsx @@ -24,12 +24,14 @@ import TimelineItemCard from "@/components/card/TimelineItemCard"; import { getTimelineHoursForDay } from "@/utils/historyUtil"; type HistoryTimelineViewProps = { - playback: TimelinePlayback; + timelineData: CardsData; + initialPlayback: TimelinePlayback; isMobile: boolean; }; export default function HistoryTimelineView({ - playback, + timelineData, + initialPlayback, isMobile, }: HistoryTimelineViewProps) { const apiHost = useApiHost(); @@ -40,7 +42,11 @@ export default function HistoryTimelineView({ [config] ); - const hasRelevantPreview = playback.relevantPreview != undefined; + const [selectedPlayback, setSelectedPlayback] = useState(initialPlayback); + const hasRelevantPreview = useMemo( + () => selectedPlayback.relevantPreview != undefined, + [selectedPlayback] + ); const playerRef = useRef(undefined); const previewRef = useRef(undefined); @@ -59,52 +65,44 @@ export default function HistoryTimelineView({ } return ( - (config.cameras[playback.camera]?.detect?.annotation_offset || 0) / 1000 + (config.cameras[selectedPlayback.camera]?.detect?.annotation_offset || + 0) / 1000 ); - }, [config, playback]); + }, [config, selectedPlayback]); const timelineTime = useMemo(() => { - if (!playback) { + if (!selectedPlayback || selectedPlayback.timelineItems.length == 0) { 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]); + return selectedPlayback.timelineItems.at(0)!!.timestamp; + }, [selectedPlayback]); const recordingParams = useMemo(() => { return { - before: playbackTimes.end, - after: playbackTimes.start, + before: selectedPlayback.range.end, + after: selectedPlayback.range.start, }; - }, [playbackTimes]); + }, [selectedPlayback]); const { data: recordings } = useSWR( - playback ? [`${playback.camera}/recordings`, recordingParams] : null, + selectedPlayback + ? [`${selectedPlayback.camera}/recordings`, recordingParams] + : null, { revalidateOnFocus: false } ); const playbackUri = useMemo(() => { - if (!playback) { + if (!selectedPlayback) { return ""; } - const date = new Date(playbackTimes.start * 1000); + const date = new Date(selectedPlayback.range.start * 1000); return `${apiHost}vod/${date.getFullYear()}-${ date.getMonth() + 1 }/${date.getDate()}/${date.getHours()}/${ - playback.camera + selectedPlayback.camera }/${timezone.replaceAll("/", ",")}/master.m3u8`; - }, [playbackTimes]); + }, [selectedPlayback]); const onSelectItem = useCallback( (timeline: Timeline | undefined) => { @@ -151,20 +149,22 @@ export default function HistoryTimelineView({ } const seekTimestamp = data.time.getTime() / 1000; - const seekTime = seekTimestamp - playback.relevantPreview!!.start; + const seekTime = seekTimestamp - selectedPlayback.relevantPreview!!.start; setTimeToSeek(Math.round(seekTime)); }, - [scrubbing, playerRef] + [scrubbing, playerRef, selectedPlayback] ); const onStopScrubbing = useCallback( (data: { time: Date }) => { const playbackTime = data.time.getTime() / 1000; - playerRef.current?.currentTime(playbackTime - playbackTimes.start); + playerRef.current?.currentTime( + playbackTime - selectedPlayback.range.start + ); setScrubbing(false); playerRef.current?.play(); }, - [playerRef] + [selectedPlayback, playerRef] ); // handle seeking to next frame when seek is finished @@ -189,9 +189,8 @@ export default function HistoryTimelineView({ config={config} playerRef={playerRef} previewRef={previewRef} - playback={playback} + playback={selectedPlayback} playbackUri={playbackUri} - playbackTimes={playbackTimes} timelineTime={timelineTime} hasRelevantPreview={hasRelevantPreview} scrubbing={scrubbing} @@ -209,9 +208,10 @@ export default function HistoryTimelineView({ config={config} playerRef={playerRef} previewRef={previewRef} - playback={playback} + timelineData={timelineData} + selectedPlayback={selectedPlayback} + setSelectedPlayback={setSelectedPlayback} playbackUri={playbackUri} - playbackTimes={playbackTimes} timelineTime={timelineTime} hasRelevantPreview={hasRelevantPreview} scrubbing={scrubbing} @@ -228,9 +228,10 @@ type DesktopViewProps = { config: FrigateConfig; playerRef: React.MutableRefObject; previewRef: React.MutableRefObject; - playback: TimelinePlayback; + timelineData: CardsData; + selectedPlayback: TimelinePlayback; + setSelectedPlayback: (timeline: TimelinePlayback) => void; playbackUri: string; - playbackTimes: { start: number; end: number }; timelineTime: number; hasRelevantPreview: boolean; scrubbing: boolean; @@ -244,9 +245,10 @@ function DesktopView({ config, playerRef, previewRef, - playback, + timelineData, + selectedPlayback, + setSelectedPlayback, playbackUri, - playbackTimes, timelineTime, hasRelevantPreview, scrubbing, @@ -257,7 +259,13 @@ function DesktopView({ onStopScrubbing, }: DesktopViewProps) { const timelineStack = - playback == undefined ? [] : getTimelineHoursForDay(timelineTime); + selectedPlayback == undefined + ? [] + : getTimelineHoursForDay( + selectedPlayback.camera, + timelineData, + timelineTime + ); return (
@@ -283,7 +291,9 @@ function DesktopView({ seekOptions={{ forward: 10, backward: 5 }} onReady={(player) => { playerRef.current = player; - player.currentTime(timelineTime - playbackTimes.start); + player.currentTime( + timelineTime - selectedPlayback.range.start + ); player.on("playing", () => { onSelectItem(undefined); }); @@ -295,7 +305,7 @@ function DesktopView({ {config && focusedItem ? ( ) : undefined} @@ -311,7 +321,7 @@ function DesktopView({ loadingSpinner: false, sources: [ { - src: `${playback.relevantPreview?.src}`, + src: `${selectedPlayback.relevantPreview?.src}`, type: "video/mp4", }, ], @@ -330,12 +340,12 @@ function DesktopView({
- {playback.timelineItems.map((timeline) => { + {selectedPlayback.timelineItems.map((timeline) => { return ( onSelectItem(timeline)} /> ); @@ -343,38 +353,42 @@ function DesktopView({
- {timelineStack.map((range) => { - const isSelected = timelineTime > range.start && timelineTime < range.end; + {timelineStack.map((timeline) => { + const isSelected = + timeline.range.start == selectedPlayback.range.start; return (
{ - if (data.items.length > 0) { - const selected = data.items[0]; - onSelectItem( - playback.timelineItems.find( - (timeline) => timeline.timestamp == selected - ) - ); + key={timeline.range.start} + items={[]} + timeBars={ + hasRelevantPreview + ? [{ time: new Date(timelineTime * 1000), id: "playback" }] + : [] } - }} - /> + options={{ + snap: null, + min: new Date(timeline.range.start * 1000), + max: new Date(timeline.range.end * 1000), + zoomable: false, + }} + timechangeHandler={onScrubTime} + timechangedHandler={onStopScrubbing} + doubleClickHandler={(data) => { + setSelectedPlayback(timeline); + }} + selectHandler={(data) => { + if (data.items.length > 0) { + const selected = data.items[0]; + onSelectItem( + selectedPlayback.timelineItems.find( + (timeline) => timeline.timestamp == selected + ) + ); + } + }} + />
); })} @@ -389,7 +403,6 @@ type MobileViewProps = { previewRef: React.MutableRefObject; playback: TimelinePlayback; playbackUri: string; - playbackTimes: { start: number; end: number }; timelineTime: number; hasRelevantPreview: boolean; scrubbing: boolean; @@ -405,7 +418,6 @@ function MobileView({ previewRef, playback, playbackUri, - playbackTimes, timelineTime, hasRelevantPreview, scrubbing, @@ -437,7 +449,7 @@ function MobileView({ seekOptions={{ forward: 10, backward: 5 }} onReady={(player) => { playerRef.current = player; - player.currentTime(timelineTime - playbackTimes.start); + player.currentTime(timelineTime - playback.range.start); player.on("playing", () => { onSelectItem(undefined); }); @@ -493,14 +505,14 @@ function MobileView({ } options={{ start: new Date( - Math.max(playbackTimes.start, timelineTime - 300) * 1000 + Math.max(playback.range.start, timelineTime - 300) * 1000 ), end: new Date( - Math.min(playbackTimes.end, timelineTime + 300) * 1000 + Math.min(playback.range.end, timelineTime + 300) * 1000 ), snap: null, - min: new Date(playbackTimes.start * 1000), - max: new Date(playbackTimes.end * 1000), + min: new Date(playback.range.start * 1000), + max: new Date(playback.range.end * 1000), timeAxis: { scale: "minute", step: 5 }, }} timechangeHandler={onScrubTime}