diff --git a/web/src/components/card/TimelineItemCard.tsx b/web/src/components/card/TimelineItemCard.tsx new file mode 100644 index 000000000..9aa13c9e4 --- /dev/null +++ b/web/src/components/card/TimelineItemCard.tsx @@ -0,0 +1,76 @@ +import { getTimelineItemDescription } from "@/utils/timelineUtil"; +import { Button } from "../ui/button"; +import Logo from "../Logo"; +import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import VideoPlayer from "../player/VideoPlayer"; +import { Card } from "../ui/card"; + +type TimelineItemCardProps = { + timeline: Timeline; + relevantPreview: Preview | undefined; + onSelect: () => void; +}; +export default function TimelineItemCard({ + timeline, + relevantPreview, + onSelect, +}: TimelineItemCardProps) { + const { data: config } = useSWR("config"); + + return ( + +
+ {relevantPreview && ( + { + player.pause(); // autoplay + pause is required for iOS + player.currentTime(timeline.timestamp - relevantPreview.start); + }} + /> + )} +
+
+
+ {getTimelineItemDescription(timeline)} +
+
+ {formatUnixTimestampToDateTime(timeline.timestamp, { + strftime_fmt: + config?.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S %p", + time_style: "medium", + date_style: "medium", + })} +
+ +
+
+ ); +} diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index f27d7a2fb..47d232409 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -186,8 +186,8 @@ function PreviewContent({ const touchEnd = new Date().getTime(); - // consider tap less than 500 ms - if (touchEnd - touchStart < 500) { + // consider tap less than 300 ms + if (touchEnd - touchStart < 300) { onClick(); } }); @@ -214,10 +214,11 @@ function PreviewContent({ } else { return ( <> -
+
([]); const defaultOptions: TimelineOptions = { width: "100%", @@ -161,6 +164,41 @@ function ActivityScrubber({ }; }, [containerRef]); + // need to keep custom times in sync + useEffect(() => { + if (!timelineRef.current.timeline || timeBars == undefined) { + return; + } + + setCustomTimes((prevTimes) => { + if (prevTimes.length == 0 && timeBars.length == 0) { + return []; + } + + prevTimes + .filter((x) => timeBars.find((y) => x.id == y.id) == undefined) + .forEach((time) => { + try { + timelineRef.current.timeline?.removeCustomTime(time.id); + } catch {} + }); + + timeBars.forEach((time) => { + try { + const existing = timelineRef.current.timeline?.getCustomTime(time.id); + + if (existing != time.time) { + timelineRef.current.timeline?.setCustomTime(time.time, time.id); + } + } catch { + timelineRef.current.timeline?.addCustomTime(time.time, time.id); + } + }); + + return timeBars; + }); + }, [timeBars, timelineRef]); + return (
diff --git a/web/src/pages/History.tsx b/web/src/pages/History.tsx index cc0bca56a..6062e66b8 100644 --- a/web/src/pages/History.tsx +++ b/web/src/pages/History.tsx @@ -19,12 +19,13 @@ import { 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"; +import MobileTimelineView from "@/views/history/MobileTimelineView"; +import DesktopTimelineView from "@/views/history/DesktopTimelineView"; const API_LIMIT = 200; @@ -76,10 +77,25 @@ function History() { setSize, isValidating, } = useSWRInfinite(getKey, timelineFetcher); + + const previewTimes = useMemo(() => { + if (!timelinePages) { + return undefined; + } + + const startDate = new Date(); + startDate.setMinutes(0, 0, 0); + + const endDate = new Date(timelinePages.at(-1)!!.end); + endDate.setHours(0, 0, 0, 0); + return { + start: startDate.getTime() / 1000, + end: endDate.getTime() / 1000, + }; + }, [timelinePages]); const { data: allPreviews } = useSWR( - timelinePages - ? `preview/all/start/${timelinePages?.at(0) - ?.start}/end/${timelinePages?.at(-1)?.end}` + previewTimes + ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` : null, { revalidateOnFocus: false } ); @@ -104,9 +120,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 +168,7 @@ function History() { } }, [itemsToDelete, updateHistory]); - if (!config || !timelineCards || timelineCards.length == 0) { + if (!config || !timelineCards) { return ; } @@ -217,6 +233,8 @@ function History() { onItemSelected={(item) => setPlaybackState(item)} /> setPlaybackState(undefined)} @@ -226,25 +244,37 @@ function History() { } type TimelineViewerProps = { + timelineData: CardsData | undefined; + allPreviews: Preview[]; playback: TimelinePlayback | undefined; isMobile: boolean; onClose: () => void; }; -function TimelineViewer({ playback, isMobile, onClose }: TimelineViewerProps) { +function TimelineViewer({ + timelineData, + allPreviews, + playback, + isMobile, + onClose, +}: TimelineViewerProps) { if (isMobile) { return playback != undefined ? (
- + {timelineData && }
) : null; } 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 b372dc1c2..78bfe77cb 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"]) @@ -101,3 +101,83 @@ export function getHourlyTimelineData( return cards; } + +export function getTimelineHoursForDay( + camera: string, + cards: CardsData, + allPreviews: Preview[], + timestamp: number +): TimelinePlayback[] { + const now = new Date(); + const data: TimelinePlayback[] = []; + const startDay = new Date(timestamp * 1000); + startDay.setHours(23, 59, 59, 999); + const dayEnd = startDay.getTime() / 1000; + startDay.setHours(0, 0, 0, 0); + let start = startDay.getTime() / 1000; + let end = 0; + + const relevantPreviews = allPreviews.filter((preview) => { + return ( + preview.camera == camera && + preview.start >= start && + Math.floor(preview.end - 1) <= dayEnd + ); + }); + + 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); + + if (startDay > now) { + break; + } + + end = startDay.getTime() / 1000; + 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 []; + }) + : []; + const relevantPreview = relevantPreviews.find( + (preview) => + Math.round(preview.start) >= start && Math.floor(preview.end) <= end + ); + data.push({ + camera, + range: { start, end }, + timelineItems, + relevantPreview, + }); + start = startDay.getTime() / 1000; + } + + return data.reverse(); +} diff --git a/web/src/views/history/DesktopTimelineView.tsx b/web/src/views/history/DesktopTimelineView.tsx new file mode 100644 index 000000000..72fbd9b6d --- /dev/null +++ b/web/src/views/history/DesktopTimelineView.tsx @@ -0,0 +1,347 @@ +import { useApiHost } from "@/api"; +import TimelineEventOverlay from "@/components/overlay/TimelineDataOverlay"; +import VideoPlayer from "@/components/player/VideoPlayer"; +import ActivityScrubber from "@/components/scrubber/ActivityScrubber"; +import ActivityIndicator from "@/components/ui/activity-indicator"; +import { FrigateConfig } from "@/types/frigateConfig"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import useSWR from "swr"; +import Player from "video.js/dist/types/player"; +import TimelineItemCard from "@/components/card/TimelineItemCard"; +import { getTimelineHoursForDay } from "@/utils/historyUtil"; + +type DesktopTimelineViewProps = { + timelineData: CardsData; + allPreviews: Preview[]; + initialPlayback: TimelinePlayback; +}; + +export default function DesktopTimelineView({ + timelineData, + allPreviews, + initialPlayback, +}: DesktopTimelineViewProps) { + const apiHost = useApiHost(); + const { data: config } = useSWR("config"); + const timezone = useMemo( + () => + config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + [config] + ); + + const [selectedPlayback, setSelectedPlayback] = useState(initialPlayback); + + 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 [timelineTime, setTimelineTime] = useState( + initialPlayback.timelineItems.length > 0 + ? initialPlayback.timelineItems[0].timestamp - initialPlayback.range.start + : 0 + ); + + const annotationOffset = useMemo(() => { + if (!config) { + return 0; + } + + return ( + (config.cameras[initialPlayback.camera]?.detect?.annotation_offset || 0) / + 1000 + ); + }, [config]); + + const recordingParams = useMemo(() => { + return { + before: selectedPlayback.range.end, + after: selectedPlayback.range.start, + }; + }, [selectedPlayback]); + const { data: recordings } = useSWR( + selectedPlayback + ? [`${selectedPlayback.camera}/recordings`, recordingParams] + : null, + { revalidateOnFocus: false } + ); + + const playbackUri = useMemo(() => { + if (!selectedPlayback) { + return ""; + } + + const date = new Date(selectedPlayback.range.start * 1000); + return `${apiHost}vod/${date.getFullYear()}-${ + date.getMonth() + 1 + }/${date.getDate()}/${date.getHours()}/${ + selectedPlayback.camera + }/${timezone.replaceAll("/", ",")}/master.m3u8`; + }, [selectedPlayback]); + + const onSelectItem = useCallback( + (timeline: Timeline | undefined) => { + if (timeline) { + setFocusedItem(timeline); + const selected = timeline.timestamp; + 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); + } else { + setFocusedItem(undefined); + } + }, + [annotationOffset, recordings, 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]); + + // handle loading main / preview playback when selected hour changes + useEffect(() => { + if (!playerRef.current || !previewRef.current) { + return; + } + + setTimelineTime( + selectedPlayback.timelineItems.length > 0 + ? selectedPlayback.timelineItems[0].timestamp + : selectedPlayback.range.start + ); + + playerRef.current.src({ + src: playbackUri, + type: "application/vnd.apple.mpegurl", + }); + + if (selectedPlayback.relevantPreview) { + previewRef.current.src({ + src: selectedPlayback.relevantPreview.src, + type: selectedPlayback.relevantPreview.type, + }); + } + }, [playerRef, previewRef, selectedPlayback]); + + const timelineStack = useMemo( + () => + getTimelineHoursForDay( + selectedPlayback.camera, + timelineData, + allPreviews, + selectedPlayback.range.start + 60 + ), + [] + ); + + if (!config) { + return ; + } + + return ( +
+
+ <> +
+
+ { + playerRef.current = player; + + if (selectedPlayback.timelineItems.length > 0) { + player.currentTime( + selectedPlayback.timelineItems[0].timestamp - + selectedPlayback.range.start + ); + } else { + player.currentTime(0); + } + player.on("playing", () => onSelectItem(undefined)); + player.on("timeupdate", () => { + setTimelineTime(Math.floor(player.currentTime() || 0)); + }); + }} + onDispose={() => { + playerRef.current = undefined; + }} + > + {focusedItem && ( + + )} + +
+ {selectedPlayback.relevantPreview && ( +
+ { + previewRef.current = player; + player.on("seeked", () => setSeeking(false)); + }} + onDispose={() => { + previewRef.current = undefined; + }} + /> +
+ )} +
+ +
+ {selectedPlayback.timelineItems.map((timeline) => { + return ( + onSelectItem(timeline)} + /> + ); + })} +
+
+
+ {timelineStack.map((timeline) => { + const isSelected = + timeline.range.start == selectedPlayback.range.start; + + return ( +
+ { + if (!timeline.relevantPreview) { + return; + } + + if (playerRef.current?.paused() == false) { + setScrubbing(true); + playerRef.current?.pause(); + } + + const seekTimestamp = data.time.getTime() / 1000; + const seekTime = + seekTimestamp - timeline.relevantPreview.start; + setTimelineTime(seekTimestamp - timeline.range.start); + setTimeToSeek(Math.round(seekTime)); + }} + timechangedHandler={(data) => { + const playbackTime = data.time.getTime() / 1000; + playerRef.current?.currentTime( + playbackTime - timeline.range.start + ); + setScrubbing(false); + playerRef.current?.play(); + }} + doubleClickHandler={() => { + setSelectedPlayback(timeline); + }} + selectHandler={(data) => { + if (data.items.length > 0) { + const selected = data.items[0]; + onSelectItem( + selectedPlayback.timelineItems.find( + (timeline) => timeline.timestamp == selected + ) + ); + } + }} + /> +
+ ); + })} +
+
+ ); +} 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/MobileTimelineView.tsx similarity index 75% rename from web/src/views/history/HistoryTimelineView.tsx rename to web/src/views/history/MobileTimelineView.tsx index a892f8e9d..23001bce8 100644 --- a/web/src/views/history/HistoryTimelineView.tsx +++ b/web/src/views/history/MobileTimelineView.tsx @@ -15,15 +15,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import Player from "video.js/dist/types/player"; -type HistoryTimelineViewProps = { +type MobileTimelineViewProps = { playback: TimelinePlayback; - isMobile: boolean; }; -export default function HistoryTimelineView({ +export default function MobileTimelineView({ playback, - isMobile, -}: HistoryTimelineViewProps) { +}: MobileTimelineViewProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); const timezone = useMemo( @@ -32,8 +30,6 @@ export default function HistoryTimelineView({ [config] ); - const hasRelevantPreview = playback.relevantPreview != undefined; - const playerRef = useRef(undefined); const previewRef = useRef(undefined); @@ -53,33 +49,20 @@ export default function HistoryTimelineView({ return ( (config.cameras[playback.camera]?.detect?.annotation_offset || 0) / 1000 ); - }, [config, playback]); + }, [config]); - 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 [timelineTime, setTimelineTime] = useState( + playback.timelineItems.length > 0 + ? playback.timelineItems[0].timestamp + : playback.range.start + ); const recordingParams = useMemo(() => { return { - before: playbackTimes.end, - after: playbackTimes.start, + before: playback.range.end, + after: playback.range.start, }; - }, [playbackTimes]); + }, [playback]); const { data: recordings } = useSWR( playback ? [`${playback.camera}/recordings`, recordingParams] : null, { revalidateOnFocus: false } @@ -90,23 +73,19 @@ export default function HistoryTimelineView({ return ""; } - const date = new Date(playbackTimes.start * 1000); + const date = new Date(playback.range.start * 1000); return `${apiHost}vod/${date.getFullYear()}-${ date.getMonth() + 1 }/${date.getDate()}/${date.getHours()}/${ playback.camera }/${timezone.replaceAll("/", ",")}/master.m3u8`; - }, [playbackTimes]); + }, [playback]); const onSelectItem = useCallback( - (data: { items: number[] }) => { - if (data.items.length > 0) { - const selected = data.items[0]; - setFocusedItem( - playback.timelineItems.find( - (timeline) => timeline.timestamp == selected - ) - ); + (timeline: Timeline | undefined) => { + if (timeline) { + setFocusedItem(timeline); + const selected = timeline.timestamp; playerRef.current?.pause(); let seekSeconds = 0; @@ -128,6 +107,8 @@ export default function HistoryTimelineView({ return true; }); playerRef.current?.currentTime(seekSeconds); + } else { + setFocusedItem(undefined); } }, [annotationOffset, recordings, playerRef] @@ -135,7 +116,7 @@ export default function HistoryTimelineView({ const onScrubTime = useCallback( (data: { time: Date }) => { - if (!hasRelevantPreview) { + if (!playback.relevantPreview) { return; } @@ -145,20 +126,21 @@ export default function HistoryTimelineView({ } const seekTimestamp = data.time.getTime() / 1000; - const seekTime = seekTimestamp - playback.relevantPreview!!.start; + const seekTime = seekTimestamp - playback.relevantPreview.start; + setTimelineTime(seekTimestamp); setTimeToSeek(Math.round(seekTime)); }, - [scrubbing, playerRef] + [scrubbing, playerRef, playback] ); const onStopScrubbing = useCallback( (data: { time: Date }) => { const playbackTime = data.time.getTime() / 1000; - playerRef.current?.currentTime(playbackTime - playbackTimes.start); + playerRef.current?.currentTime(playbackTime - playback.range.start); setScrubbing(false); playerRef.current?.play(); }, - [playerRef] + [playback, playerRef] ); // handle seeking to next frame when seek is finished @@ -182,7 +164,7 @@ export default function HistoryTimelineView({ <>
{ playerRef.current = player; - player.currentTime(timelineTime - playbackTimes.start); + player.currentTime(timelineTime - playback.range.start); player.on("playing", () => { - setFocusedItem(undefined); + onSelectItem(undefined); }); }} onDispose={() => { @@ -216,7 +198,7 @@ export default function HistoryTimelineView({ ) : undefined}
- {hasRelevantPreview && ( + {playback.relevantPreview && (
{ + if (data.items.length > 0) { + const selected = data.items[0]; + onSelectItem( + playback.timelineItems.find( + (timeline) => timeline.timestamp == selected + ) + ); + } + }} /> )}