diff --git a/web/src/App.tsx b/web/src/App.tsx index af3483004..182b8fffe 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,7 +5,6 @@ import Wrapper from "@/components/Wrapper"; import Sidebar from "@/components/Sidebar"; import Header from "@/components/Header"; import Live from "@/pages/Live"; -import History from "@/pages/History"; import Export from "@/pages/Export"; import Storage from "@/pages/Storage"; import System from "@/pages/System"; @@ -40,7 +39,6 @@ function App() { } /> } /> - } /> } /> } /> } /> diff --git a/web/src/components/card/HistoryCard.tsx b/web/src/components/card/HistoryCard.tsx deleted file mode 100644 index 11f72c8c7..000000000 --- a/web/src/components/card/HistoryCard.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import useSWR from "swr"; -import { Card } from "../ui/card"; -import { FrigateConfig } from "@/types/frigateConfig"; -import ActivityIndicator from "../ui/activity-indicator"; -import { LuClock, LuTrash } from "react-icons/lu"; -import { HiOutlineVideoCamera } from "react-icons/hi"; -import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; -import { - getTimelineIcon, - getTimelineItemDescription, -} from "@/utils/timelineUtil"; -import { Button } from "../ui/button"; - -type HistoryCardProps = { - timeline: Card; - relevantPreview?: Preview; - isMobile: boolean; - onClick?: () => void; - onDelete?: () => void; -}; - -export default function HistoryCard({ - // @ts-ignore - relevantPreview, - timeline, - // @ts-ignore - isMobile, - onClick, - onDelete, -}: HistoryCardProps) { - const { data: config } = useSWR("config"); - - if (!config) { - return ; - } - - return ( - - <> -
-
- - {formatUnixTimestampToDateTime(timeline.time, { - strftime_fmt: - config.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S %p", - time_style: "medium", - date_style: "medium", - })} -
- -
-
- - {timeline.camera.replaceAll("_", " ")} -
-
-
Activity:
- {Object.entries(timeline.entries).map(([_, entry], idx) => { - return ( -
- {getTimelineIcon(entry)} - {getTimelineItemDescription(entry)} -
- ); - })} -
- -
- ); -} diff --git a/web/src/components/card/TimelinePlayerCard.tsx b/web/src/components/card/TimelinePlayerCard.tsx deleted file mode 100644 index 0619d634e..000000000 --- a/web/src/components/card/TimelinePlayerCard.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; -import useSWR from "swr"; -import { FrigateConfig } from "@/types/frigateConfig"; -import VideoPlayer from "../player/VideoPlayer"; -import { useMemo, useRef, useState } from "react"; -import { useApiHost } from "@/api"; -import TimelineEventOverlay from "../overlay/TimelineDataOverlay"; -import ActivityIndicator from "../ui/activity-indicator"; -import { Button } from "../ui/button"; -import { - getTimelineIcon, - getTimelineItemDescription, -} from "@/utils/timelineUtil"; -import { LuAlertCircle } from "react-icons/lu"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../ui/tooltip"; -import Player from "video.js/dist/types/player"; - -type TimelinePlayerCardProps = { - timeline?: Card; - onDismiss: () => void; -}; - -export default function TimelinePlayerCard({ - timeline, - onDismiss, -}: TimelinePlayerCardProps) { - const { data: config } = useSWR("config"); - const apiHost = useApiHost(); - const playerRef = useRef(); - - const annotationOffset = useMemo(() => { - if (!config || !timeline) { - return 0; - } - - return ( - (config.cameras[timeline.camera]?.detect?.annotation_offset || 0) / 1000 - ); - }, [config, timeline]); - const [selectedItem, setSelectedItem] = useState(); - - const recordingParams = useMemo(() => { - if (!timeline) { - return {}; - } - - return { - before: timeline.entries.at(-1)!!.timestamp + 30, - after: timeline.entries.at(0)!!.timestamp, - }; - }, [timeline]); - - const { data: recordings } = useSWR( - timeline ? [`${timeline.camera}/recordings`, recordingParams] : null, - { revalidateOnFocus: false } - ); - - const playbackUri = useMemo(() => { - if (!timeline) { - return ""; - } - - const end = timeline.entries.at(-1)!!.timestamp + 30; - const start = timeline.entries.at(0)!!.timestamp; - return `${apiHost}vod/${timeline?.camera}/start/${ - Number.isInteger(start) ? start.toFixed(1) : start - }/end/${Number.isInteger(end) ? end.toFixed(1) : end}/master.m3u8`; - }, [timeline]); - - return ( - <> - { - setSelectedItem(undefined); - onDismiss(); - }} - > - e.preventDefault()} - > - - - {`${timeline?.camera?.replaceAll( - "_", - " " - )} @ ${formatUnixTimestampToDateTime(timeline?.time ?? 0, { - strftime_fmt: - config?.ui?.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S", - })}`} - - - {config && timeline && recordings && recordings.length > 0 && ( - <> - { - setSelectedItem(selected); - playerRef.current?.pause(); - playerRef.current?.currentTime(seekTime); - }} - /> -
- { - playerRef.current = player; - player.on("playing", () => { - setSelectedItem(undefined); - }); - }} - onDispose={() => { - playerRef.current = undefined; - }} - > - {selectedItem ? ( - - ) : undefined} - -
- - )} -
-
- - ); -} - -type TimelineSummaryProps = { - timeline: Card; - annotationOffset: number; - recordings: Recording[]; - onFrameSelected: (timeline: Timeline, frameTime: number) => void; -}; - -function TimelineSummary({ - timeline, - annotationOffset, - recordings, - onFrameSelected, -}: TimelineSummaryProps) { - const [timeIndex, setTimeIndex] = useState(-1); - - // calculates the seek seconds by adding up all the seconds in the segments prior to the playback time - const getSeekSeconds = (seekUnix: number) => { - if (!recordings) { - return 0; - } - - let seekSeconds = 0; - recordings.every((segment) => { - // if the next segment is past the desired time, stop calculating - if (segment.start_time > seekUnix) { - return false; - } - - if (segment.end_time < seekUnix) { - seekSeconds += segment.end_time - segment.start_time; - return true; - } - - seekSeconds += - segment.end_time - segment.start_time - (segment.end_time - seekUnix); - return true; - }); - - return seekSeconds; - }; - - const onSelectMoment = async (index: number) => { - setTimeIndex(index); - onFrameSelected( - timeline.entries[index], - getSeekSeconds(timeline.entries[index].timestamp + annotationOffset) - ); - }; - - if (!timeline || !recordings) { - return ; - } - - return ( -
-
-
- {timeline.entries.map((item, index) => ( - - - - - - -

{getTimelineItemDescription(item)}

-
-
-
- ))} -
-
- {timeIndex >= 0 ? ( -
-
-
- Bounding boxes may not align -
- - - - - - -

- Disclaimer: This data comes from the detect feed but is - shown on the recordings. -

-

- It is unlikely that the streams are perfectly in sync so the - bounding box and the footage will not line up perfectly. -

-

The annotation_offset field can be used to adjust this.

-
-
-
-
-
- ) : null} -
- ); -} diff --git a/web/src/components/filter/HistoryFilterPopover.tsx b/web/src/components/filter/HistoryFilterPopover.tsx index 45f5ad2c5..d293e266e 100644 --- a/web/src/components/filter/HistoryFilterPopover.tsx +++ b/web/src/components/filter/HistoryFilterPopover.tsx @@ -16,7 +16,9 @@ import { import { Calendar } from "../ui/calendar"; type HistoryFilterPopoverProps = { + // @ts-ignore filter: HistoryFilter | undefined; + // @ts-ignore onUpdateFilter: (filter: HistoryFilter) => void; }; diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index b3c0c36ab..8cd510a1a 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -23,6 +23,7 @@ type PreviewPlayerProps = { relevantPreview?: Preview; autoPlayback?: boolean; setReviewed?: () => void; + onClick?: () => void; }; type Preview = { @@ -38,6 +39,7 @@ export default function PreviewThumbnailPlayer({ relevantPreview, autoPlayback = false, setReviewed, + onClick, }: PreviewPlayerProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); @@ -109,6 +111,7 @@ export default function PreviewThumbnailPlayer({ className="relative w-full h-full cursor-pointer" onMouseEnter={isMobile ? undefined : () => onPlayback(true)} onMouseLeave={isMobile ? undefined : () => onPlayback(false)} + onClick={onClick} > {playingBack ? ( { + if (!relevantPreview) { + return 0; + } + + // start with a bit of padding + return Math.max(0, review.start_time - relevantPreview.start - 8); + }, []); + // manual playback // safari is incapable of playing at a speed > 2x // so manual seeking is required on iOS @@ -195,9 +207,11 @@ function PreviewContent({ return; } + let counter = 0; const intervalId: NodeJS.Timeout = setInterval(() => { if (playerRef.current) { - playerRef.current.currentTime(playerRef.current.currentTime()!! + 1); + playerRef.current.currentTime(playerStartTime + counter); + counter += 1; } }, 125); return () => clearInterval(intervalId); @@ -233,20 +247,15 @@ function PreviewContent({ return; } - // start with a bit of padding - const playerStartTime = Math.max( - 0, - review.start_time - relevantPreview.start - 8 - ); - if (isSafari) { player.pause(); setManualPlayback(true); } else { + player.currentTime(playerStartTime); player.playbackRate(8); } - player.currentTime(playerStartTime); + let lastPercent = 0; player.on("timeupdate", () => { if (!setProgress) { return; @@ -262,11 +271,14 @@ function PreviewContent({ if ( setReviewed && !review.has_been_reviewed && + lastPercent < 50 && playerPercent > 50 ) { setReviewed(); } + lastPercent = playerPercent; + if (playerPercent > 100) { playerRef.current?.pause(); setManualPlayback(false); diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index aeedd89a5..7240934c0 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -25,6 +25,7 @@ export type EventReviewTimelineProps = { events: ReviewSegment[]; severityType: ReviewSeverity; contentRef: RefObject; + onHandlebarDraggingChange?: (isDragging: boolean) => void; }; export function EventReviewTimeline({ @@ -41,6 +42,7 @@ export function EventReviewTimeline({ events, severityType, contentRef, + onHandlebarDraggingChange, }: EventReviewTimelineProps) { const [isDragging, setIsDragging] = useState(false); const [currentTimeSegment, setCurrentTimeSegment] = useState(0); @@ -152,6 +154,12 @@ export function EventReviewTimeline({ } }, [currentTimeSegment, showHandlebar]); + useEffect(() => { + if (onHandlebarDraggingChange) { + onHandlebarDraggingChange(isDragging); + } + }, [isDragging, onHandlebarDraggingChange]); + useEffect(() => { if (timelineRef.current && handlebarTime && showHandlebar) { const { scrollHeight: timelineHeight } = timelineRef.current; diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 0a465afe8..77c2a3f61 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,11 +1,213 @@ +import useOverlayState from "@/hooks/use-overlay-state"; +import { ReviewSegment } from "@/types/review"; import DesktopEventView from "@/views/events/DesktopEventView"; +import DesktopRecordingView from "@/views/events/DesktopRecordingView"; import MobileEventView from "@/views/events/MobileEventView"; -import { isMobile } from 'react-device-detect'; +import axios from "axios"; +import { useCallback, useMemo } from "react"; +import { isMobile } from "react-device-detect"; +import useSWR from "swr"; +import useSWRInfinite from "swr/infinite"; + +const API_LIMIT = 250; export default function Events() { - if (isMobile) { - return ; - } + // recordings viewer + const [selectedReviewId, setSelectedReviewId] = useOverlayState("review"); - return ; + // review paging + + const timeRange = useMemo(() => { + return { before: Date.now() / 1000, after: getHoursAgo(24) }; + }, []); + + const reviewSegmentFetcher = useCallback((key: any) => { + const [path, params] = Array.isArray(key) ? key : [key, undefined]; + return axios.get(path, { params }).then((res) => res.data); + }, []); + + const reviewSearchParams = {}; + const getKey = useCallback( + (index: number, prevData: ReviewSegment[]) => { + if (index > 0) { + const lastDate = prevData[prevData.length - 1].start_time; + const pagedParams = reviewSearchParams + ? { before: lastDate, after: timeRange.after, limit: API_LIMIT } + : { + ...reviewSearchParams, + before: lastDate, + after: timeRange.after, + limit: API_LIMIT, + }; + return ["review", pagedParams]; + } + + const params = reviewSearchParams + ? { limit: API_LIMIT, before: timeRange.before, after: timeRange.after } + : { + ...reviewSearchParams, + limit: API_LIMIT, + before: timeRange.before, + after: timeRange.after, + }; + return ["review", params]; + }, + [reviewSearchParams] + ); + + const { + data: reviewPages, + mutate: updateSegments, + size, + setSize, + isValidating, + } = useSWRInfinite(getKey, reviewSegmentFetcher, { + revalidateOnFocus: false, + persistSize: true, + }); + + const isDone = useMemo( + () => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT, + [reviewPages] + ); + + // preview videos + + const previewTimes = useMemo(() => { + if ( + !reviewPages || + reviewPages.length == 0 || + reviewPages.at(-1)!!.length == 0 + ) { + return undefined; + } + + const startDate = new Date(); + startDate.setMinutes(0, 0, 0); + + const endDate = new Date(reviewPages.at(-1)!!.at(-1)!!.end_time); + endDate.setHours(0, 0, 0, 0); + return { + start: startDate.getTime() / 1000, + end: endDate.getTime() / 1000, + }; + }, [reviewPages]); + const { data: allPreviews } = useSWR( + previewTimes + ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` + : null, + { revalidateOnFocus: false } + ); + + // review status + + const markItemAsReviewed = useCallback( + async (reviewId: string) => { + const resp = await axios.post(`review/${reviewId}/viewed`); + + if (resp.status == 200) { + updateSegments( + (data: ReviewSegment[][] | undefined) => { + if (!data) { + return data; + } + + const newData: ReviewSegment[][] = []; + + data.forEach((page) => { + const reviewIndex = page.findIndex((item) => item.id == reviewId); + + if (reviewIndex == -1) { + newData.push([...page]); + } else { + newData.push([ + ...page.slice(0, reviewIndex), + { ...page[reviewIndex], has_been_reviewed: true }, + ...page.slice(reviewIndex + 1), + ]); + } + }); + + return newData; + }, + { revalidate: false } + ); + } + }, + [updateSegments] + ); + + // selected items + + const selectedData = useMemo(() => { + if (!selectedReviewId) { + return undefined; + } + + if (!reviewPages) { + return undefined; + } + + const allReviews = reviewPages.flat(); + const selectedReview = allReviews.find( + (item) => item.id == selectedReviewId + ); + + if (!selectedReview) { + return undefined; + } + + return { + selected: selectedReview, + cameraSegments: allReviews.filter( + (seg) => seg.camera == selectedReview.camera + ), + cameraPreviews: allPreviews?.filter( + (seg) => seg.camera == selectedReview.camera + ), + }; + }, [selectedReviewId, reviewPages]); + + if (selectedData) { + return ( + + ); + } else { + if (isMobile) { + return ( + setSize(size + 1)} + markItemAsReviewed={markItemAsReviewed} + /> + ); + } + + return ( + setSize(size + 1)} + markItemAsReviewed={markItemAsReviewed} + onSelectReview={setSelectedReviewId} + pullLatestData={updateSegments} + /> + ); + } +} + +function getHoursAgo(hours: number): number { + const now = new Date(); + now.setHours(now.getHours() - hours); + return now.getTime() / 1000; } diff --git a/web/src/pages/History.tsx b/web/src/pages/History.tsx deleted file mode 100644 index 5bfff0426..000000000 --- a/web/src/pages/History.tsx +++ /dev/null @@ -1,284 +0,0 @@ -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 axios from "axios"; -import { getHourlyTimelineData } from "@/utils/historyUtil"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} 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 { 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; - -function History() { - const { data: config } = useSWR("config"); - const timezone = useMemo( - () => - config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, - [config] - ); - - const [historyFilter, setHistoryFilter, historySearchParams] = - useApiFilter(); - - const timelineFetcher = useCallback((key: any) => { - const [path, params] = Array.isArray(key) ? key : [key, undefined]; - return axios.get(path, { params }).then((res) => res.data); - }, []); - - const getKey = useCallback( - (index: number, prevData: HourlyTimeline) => { - if (index > 0) { - const lastDate = prevData.end; - const pagedParams = - historySearchParams == undefined - ? { before: lastDate, timezone, limit: API_LIMIT } - : { - ...historySearchParams, - before: lastDate, - timezone, - limit: API_LIMIT, - }; - return ["timeline/hourly", pagedParams]; - } - - const params = - historySearchParams == undefined - ? { timezone, limit: API_LIMIT } - : { ...historySearchParams, timezone, limit: API_LIMIT }; - return ["timeline/hourly", params]; - }, - [historySearchParams] - ); - - const { - data: timelinePages, - mutate: updateHistory, - size, - 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( - previewTimes - ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` - : null, - { revalidateOnFocus: false } - ); - - 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 isMobile = useMemo(() => { - return window.innerWidth < 768; - }, [playback]); - - const timelineCards: CardsData = useMemo(() => { - if (!timelinePages) { - return {}; - } - - return getHourlyTimelineData( - timelinePages, - historyFilter?.detailLevel ?? "normal" - ); - }, [historyFilter, timelinePages]); - - const isDone = - (timelinePages?.[timelinePages.length - 1]?.count ?? 0) < API_LIMIT; - - const [itemsToDelete, setItemsToDelete] = useState(null); - const onDelete = useCallback( - async (timeline: Card) => { - if (timeline.entries.length > 1) { - const uniqueEvents = new Set( - timeline.entries.map((entry) => entry.source_id) - ); - setItemsToDelete(new Array(...uniqueEvents)); - } else { - const response = await axios.delete( - `events/${timeline.entries[0].source_id}` - ); - if (response.status === 200) { - updateHistory(); - } - } - }, - [updateHistory] - ); - const onDeleteMulti = useCallback(async () => { - if (!itemsToDelete) { - return; - } - - const responses = itemsToDelete.map(async (id) => { - return axios.delete(`events/${id}`); - }); - - if ((await responses[0]).status == 200) { - updateHistory(); - setItemsToDelete(null); - } - }, [itemsToDelete, updateHistory]); - - if (!config || !timelineCards) { - return ; - } - - return ( - <> -
-
- {viewingPlayback && ( - - )} - History -
- {!playback && ( - setHistoryFilter(filter)} - /> - )} -
- - setItemsToDelete(null)} - > - - - {`Delete ${itemsToDelete?.length} events?`} - - This will delete all events associated with these objects. - - - - setItemsToDelete(null)}> - Cancel - - onDeleteMulti()} - > - Delete - - - - - { - setSize(size + 1); - }} - onDelete={onDelete} - onItemSelected={(item) => setPlaybackState(item)} - /> - setPlaybackState(undefined)} - /> - - ); -} - -type TimelineViewerProps = { - timelineData: CardsData | undefined; - allPreviews: Preview[]; - playback: TimelinePlayback | undefined; - isMobile: boolean; - onClose: () => void; -}; - -function TimelineViewer({ - timelineData, - allPreviews, - playback, - isMobile, - onClose, -}: TimelineViewerProps) { - if (isMobile) { - return playback != undefined ? ( -
- {timelineData && } -
- ) : null; - } - - return ( - onClose()}> - - {timelineData && playback && ( - - )} - - - ); -} - -export default History; diff --git a/web/src/types/history.ts b/web/src/types/history.ts deleted file mode 100644 index 23e30c48c..000000000 --- a/web/src/types/history.ts +++ /dev/null @@ -1,43 +0,0 @@ -type CardsData = { - [day: string]: { - [hour: string]: { - [groupKey: string]: Card; - }; - }; -}; - -type Card = { - camera: string; - time: number; - entries: Timeline[]; - uniqueKeys: string[]; -}; - -type Preview = { - camera: string; - src: string; - type: string; - start: number; - end: number; -}; - -interface HistoryFilter extends FilterType { - cameras: string[]; - labels: string[]; - before: number | undefined; - after: number | undefined; - detailLevel: "normal" | "extra" | "full"; -} - -type HistoryTimeline = { - start: number; - end: number; - playbackItems: TimelinePlayback[]; -}; - -type TimelinePlayback = { - camera: string; - range: { start: number; end: number }; - timelineItems: Timeline[]; - relevantPreview: Preview | undefined; -}; diff --git a/web/src/types/preview.ts b/web/src/types/preview.ts new file mode 100644 index 000000000..7b1eb8e7d --- /dev/null +++ b/web/src/types/preview.ts @@ -0,0 +1,7 @@ +type Preview = { + camera: string; + src: string; + type: string; + start: number; + end: number; + }; \ No newline at end of file diff --git a/web/src/utils/historyUtil.ts b/web/src/utils/historyUtil.ts deleted file mode 100644 index 34ba035a2..000000000 --- a/web/src/utils/historyUtil.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { endOfHourOrCurrentTime } from "./dateUtil"; - -// group history cards by 120 seconds of activity -const GROUP_SECONDS = 120; - -export function getHourlyTimelineData( - timelinePages: HourlyTimeline[], - detailLevel: string -): CardsData { - const cards: CardsData = {}; - const allHours: { [key: string]: Timeline[] } = {}; - - timelinePages.forEach((hourlyTimeline) => { - Object.entries(hourlyTimeline.hours).forEach(([key, values]) => { - if (key in allHours) { - // only occurs when multiple pages contain elements in the same hour - allHours[key] = allHours[key] - .concat(values) - .sort((a, b) => a.timestamp - b.timestamp); - } else { - allHours[key] = values; - } - }); - }); - - Object.keys(allHours) - .sort((a, b) => a.localeCompare(b)) - .reverse() - .forEach((hour) => { - const day = new Date(parseInt(hour) * 1000); - day.setHours(0, 0, 0, 0); - const dayKey = (day.getTime() / 1000).toString(); - - // build a map of course to the types that are included in this hour - // which allows us to know what items to keep depending on detail level - const sourceToTypes: { [key: string]: string[] } = {}; - let cardTypeStart: { [camera: string]: number } = {}; - Object.values(allHours[hour]).forEach((i) => { - if (i.timestamp > (cardTypeStart[i.camera] ?? 0) + GROUP_SECONDS) { - cardTypeStart[i.camera] = i.timestamp; - } - - const groupKey = `${i.source_id}-${cardTypeStart[i.camera]}`; - - if (groupKey in sourceToTypes) { - sourceToTypes[groupKey].push(i.class_type); - } else { - sourceToTypes[groupKey] = [i.class_type]; - } - }); - - if (!(dayKey in cards)) { - cards[dayKey] = {}; - } - - if (!(hour in cards[dayKey])) { - cards[dayKey][hour] = {}; - } - - let cardStart: { [camera: string]: number } = {}; - Object.values(allHours[hour]).forEach((i) => { - if (i.timestamp > (cardStart[i.camera] ?? 0) + GROUP_SECONDS) { - cardStart[i.camera] = i.timestamp; - } - - const time = new Date(i.timestamp * 1000); - const groupKey = `${i.camera}-${cardStart[i.camera]}`; - const sourceKey = `${i.source_id}-${cardStart[i.camera]}`; - const uniqueKey = `${i.source_id}-${i.class_type}`; - - // detail level for saving items - // detail level determines which timeline items for each moment is returned - // values can be normal, extra, or full - // normal: return all items except active / attribute / gone / stationary / visible unless that is the only item. - // extra: return all items except attribute / gone / visible unless that is the only item - // full: return all items - - let add = true; - const sourceType = sourceToTypes[sourceKey]; - let hiddenItems: string[] = []; - if (detailLevel == "normal") { - hiddenItems = [ - "active", - "attribute", - "gone", - "stationary", - "visible", - ]; - } else if (detailLevel == "extra") { - hiddenItems = ["attribute", "gone", "visible"]; - } - - if (sourceType.length > 1) { - // we have multiple timeline items for this card - - if ( - sourceType.find((type) => hiddenItems.includes(type) == false) == - undefined - ) { - // all of the attribute items for this card make it hidden, but we need to show one - if (sourceType.indexOf(i.class_type) != 0) { - add = false; - } - } else if (hiddenItems.includes(i.class_type)) { - add = false; - } - } - - if (add) { - if (groupKey in cards[dayKey][hour]) { - if ( - !cards[dayKey][hour][groupKey].uniqueKeys.includes(uniqueKey) || - detailLevel == "full" - ) { - cards[dayKey][hour][groupKey].entries.push(i); - cards[dayKey][hour][groupKey].uniqueKeys.push(uniqueKey); - } - } else { - cards[dayKey][hour][groupKey] = { - camera: i.camera, - time: time.getTime() / 1000, - entries: [i], - uniqueKeys: [uniqueKey], - }; - } - } - }); - }); - - return cards; -} - -export function getTimelineHoursForDay( - camera: string, - cards: CardsData, - cameraPreviews: Preview[], - timestamp: number -): HistoryTimeline { - const endOfThisHour = new Date(); - endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0); - const data: TimelinePlayback[] = []; - const startDay = new Date(timestamp * 1000); - startDay.setHours(23, 59, 59, 999); - startDay.setHours(0, 0, 0, 0); - const startTimestamp = startDay.getTime() / 1000; - let start = startDay.getTime() / 1000; - let end = 0; - - const dayIdx = Object.keys(cards).find((day) => { - if (parseInt(day) < start) { - return false; - } - - return true; - }); - - let day: { - [hour: string]: { - [groupKey: string]: Card; - }; - } = {}; - - if (dayIdx != undefined) { - day = cards[dayIdx]; - } - - for (let i = 0; i < 24; i++) { - startDay.setHours(startDay.getHours() + 1); - - if (startDay > endOfThisHour) { - break; - } - - end = endOfHourOrCurrentTime(startDay.getTime() / 1000); - const hour = Object.values(day).find((cards) => { - const card = Object.values(cards)[0]; - if (card == undefined || card.time < start || card.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 = cameraPreviews.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 { start: startTimestamp, end, playbackItems: data.reverse() }; -} diff --git a/web/src/utils/timelineUtil.tsx b/web/src/utils/timelineUtil.tsx index 483d63231..d380bbb51 100644 --- a/web/src/utils/timelineUtil.tsx +++ b/web/src/utils/timelineUtil.tsx @@ -20,6 +20,7 @@ import { MdOutlinePictureInPictureAlt, } from "react-icons/md"; import { FaBicycle } from "react-icons/fa"; +import { endOfHourOrCurrentTime } from "./dateUtil"; export function getTimelineIcon(timelineItem: Timeline) { switch (timelineItem.class_type) { @@ -118,3 +119,31 @@ export function getTimelineItemDescription(timelineItem: Timeline) { return `${label} detected`; } } + +export function getChunkedTimeRange(timestamp: number) { + const endOfThisHour = new Date(); + endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0); + const data: { start: number; end: number }[] = []; + const startDay = new Date(timestamp * 1000); + startDay.setHours(0, 0, 0, 0); + const startTimestamp = startDay.getTime() / 1000; + let start = startDay.getTime() / 1000; + let end = 0; + + for (let i = 0; i < 24; i++) { + startDay.setHours(startDay.getHours() + 1); + + if (startDay > endOfThisHour) { + break; + } + + end = endOfHourOrCurrentTime(startDay.getTime() / 1000); + data.push({ + start, + end, + }); + start = startDay.getTime() / 1000; + } + + return { start: startTimestamp, end, ranges: data }; +} diff --git a/web/src/views/events/DesktopEventView.tsx b/web/src/views/events/DesktopEventView.tsx index ddf3c0aea..052c125c0 100644 --- a/web/src/views/events/DesktopEventView.tsx +++ b/web/src/views/events/DesktopEventView.tsx @@ -1,3 +1,4 @@ +import { useFrigateEvents } from "@/api/ws"; import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; import ActivityIndicator from "@/components/ui/activity-indicator"; @@ -12,73 +13,39 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { FrigateConfig } from "@/types/frigateConfig"; import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; -import axios from "axios"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { LuCalendar, LuFilter, LuVideo } from "react-icons/lu"; +import { LuCalendar, LuFilter, LuRefreshCcw, LuVideo } from "react-icons/lu"; import { MdCircle } from "react-icons/md"; import useSWR from "swr"; -import useSWRInfinite from "swr/infinite"; -const API_LIMIT = 250; - -export default function DesktopEventView() { +type DesktopEventViewProps = { + reviewPages?: ReviewSegment[][]; + relevantPreviews?: Preview[]; + timeRange: { before: number; after: number }; + reachedEnd: boolean; + isValidating: boolean; + loadNextPage: () => void; + markItemAsReviewed: (reviewId: string) => void; + onSelectReview: (reviewId: string) => void; + pullLatestData: () => void; +}; +export default function DesktopEventView({ + reviewPages, + relevantPreviews, + timeRange, + reachedEnd, + isValidating, + loadNextPage, + markItemAsReviewed, + onSelectReview, + pullLatestData, +}: DesktopEventViewProps) { const { data: config } = useSWR("config"); const [severity, setSeverity] = useState("alert"); const contentRef = useRef(null); // review paging - const [after, setAfter] = useState(0); - useEffect(() => { - const now = new Date(); - now.setHours(now.getHours() - 24); - setAfter(now.getTime() / 1000); - - const intervalId: NodeJS.Timeout = setInterval(() => { - const now = new Date(); - now.setHours(now.getHours() - 24); - setAfter(now.getTime() / 1000); - }, 60000); - return () => clearInterval(intervalId); - }, [60000]); - - const reviewSearchParams = {}; - const reviewSegmentFetcher = useCallback((key: any) => { - const [path, params] = Array.isArray(key) ? key : [key, undefined]; - return axios.get(path, { params }).then((res) => res.data); - }, []); - - const getKey = useCallback( - (index: number, prevData: ReviewSegment[]) => { - if (index > 0) { - const lastDate = prevData[prevData.length - 1].start_time; - const pagedParams = reviewSearchParams - ? { before: lastDate, after: after, limit: API_LIMIT } - : { - ...reviewSearchParams, - before: lastDate, - after: after, - limit: API_LIMIT, - }; - return ["review", pagedParams]; - } - - const params = reviewSearchParams - ? { limit: API_LIMIT, after: after } - : { ...reviewSearchParams, limit: API_LIMIT, after: after }; - return ["review", params]; - }, - [reviewSearchParams] - ); - - const { - data: reviewPages, - mutate: updateSegments, - size, - setSize, - isValidating, - } = useSWRInfinite(getKey, reviewSegmentFetcher); - const reviewItems = useMemo(() => { const all: ReviewSegment[] = []; const alerts: ReviewSegment[] = []; @@ -111,11 +78,6 @@ export default function DesktopEventView() { }; }, [reviewPages]); - const isDone = useMemo( - () => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT, - [reviewPages] - ); - const currentItems = useMemo(() => { const current = reviewItems[severity]; @@ -135,8 +97,8 @@ export default function DesktopEventView() { if (pagingObserver.current) pagingObserver.current.disconnect(); try { pagingObserver.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !isDone) { - setSize(size + 1); + if (entries[0].isIntersecting && !reachedEnd) { + loadNextPage(); } }); if (node) pagingObserver.current.observe(node); @@ -144,7 +106,7 @@ export default function DesktopEventView() { // no op } }, - [isValidating, isDone] + [isValidating, reachedEnd] ); const [minimap, setMinimap] = useState([]); @@ -209,46 +171,24 @@ export default function DesktopEventView() { return data; }, [minimap]); - // review status + // new data alert - const setReviewed = useCallback( - async (id: string) => { - const resp = await axios.post(`review/${id}/viewed`); - - if (resp.status == 200) { - updateSegments(); - } - }, - [updateSegments] - ); - - // preview videos - - const previewTimes = useMemo(() => { - if ( - !reviewPages || - reviewPages.length == 0 || - reviewPages.at(-1)!!.length == 0 - ) { - return undefined; + const { payload: eventUpdate } = useFrigateEvents(); + const [hasUpdate, setHasUpdate] = useState(false); + useEffect(() => { + if (!eventUpdate) { + return; } - const startDate = new Date(); - startDate.setMinutes(0, 0, 0); - - const endDate = new Date(reviewPages.at(-1)!!.at(-1)!!.end_time); - endDate.setHours(0, 0, 0, 0); - return { - start: startDate.getTime() / 1000, - end: endDate.getTime() / 1000, - }; - }, [reviewPages]); - const { data: allPreviews } = useSWR( - previewTimes - ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` - : null, - { revalidateOnFocus: false } - ); + // if event is ended and was saved, update events list + if ( + eventUpdate.type == "end" && + (eventUpdate.after.has_clip || eventUpdate.after.has_snapshot) + ) { + setHasUpdate(true); + return; + } + }, [eventUpdate]); if (!config) { return ; @@ -307,6 +247,20 @@ export default function DesktopEventView() { + {hasUpdate && ( + + )} +
{ const lastRow = segIdx == reviewItems[severity].length - 1; - const relevantPreview = Object.values(allPreviews || []).find( + const relevantPreview = Object.values(relevantPreviews || []).find( (preview) => preview.camera == value.camera && preview.start < value.start_time && @@ -331,10 +285,11 @@ export default function DesktopEventView() { setReviewed(value.id)} + setReviewed={() => markItemAsReviewed(value.id)} + onClick={() => onSelectReview(value.id)} />
- {lastRow && !isDone && } + {lastRow && !reachedEnd && } ); }) @@ -343,20 +298,18 @@ export default function DesktopEventView() { )}
- {after != 0 && ( - - )} +
); diff --git a/web/src/views/events/DesktopRecordingView.tsx b/web/src/views/events/DesktopRecordingView.tsx new file mode 100644 index 000000000..4b73e6491 --- /dev/null +++ b/web/src/views/events/DesktopRecordingView.tsx @@ -0,0 +1,119 @@ +import DynamicVideoPlayer, { + DynamicVideoController, +} from "@/components/player/DynamicVideoPlayer"; +import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; +import { Button } from "@/components/ui/button"; +import { ReviewSegment } from "@/types/review"; +import { getChunkedTimeRange } from "@/utils/timelineUtil"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { IoMdArrowRoundBack } from "react-icons/io"; +import { useNavigate } from "react-router-dom"; + +type DesktopRecordingViewProps = { + selectedReview: ReviewSegment; + reviewItems: ReviewSegment[]; + relevantPreviews?: Preview[]; +}; +export default function DesktopRecordingView({ + selectedReview, + reviewItems, + relevantPreviews, +}: DesktopRecordingViewProps) { + const navigate = useNavigate(); + const controllerRef = useRef(undefined); + const contentRef = useRef(null); + + // timeline time + + const timeRange = useMemo( + () => getChunkedTimeRange(selectedReview.start_time), + [] + ); + const [selectedRangeIdx, setSelectedRangeIdx] = useState( + timeRange.ranges.findIndex((chunk) => { + return ( + chunk.start <= selectedReview.start_time && + chunk.end >= selectedReview.start_time + ); + }) + ); + + // move to next clip + useEffect(() => { + if (!controllerRef.current) { + return; + } + + if (selectedRangeIdx < timeRange.ranges.length - 1) { + controllerRef.current.onClipEndedEvent(() => { + setSelectedRangeIdx(selectedRangeIdx + 1); + }); + } + }, [controllerRef, selectedRangeIdx]); + + // scrubbing and timeline state + + const [scrubbing, setScrubbing] = useState(false); + const [currentTime, setCurrentTime] = useState( + selectedReview?.start_time || Date.now() / 1000 + ); + + useEffect(() => { + if (scrubbing) { + controllerRef.current?.scrubToTimestamp(currentTime); + } + }, [controllerRef, currentTime, scrubbing]); + + useEffect(() => { + if (!scrubbing) { + controllerRef.current?.seekToTimestamp(currentTime, true); + } + }, [controllerRef, scrubbing]); + + return ( +
+ + +
+ { + controllerRef.current = controller; + controllerRef.current.onPlayerTimeUpdate((timestamp: number) => { + setCurrentTime(timestamp); + }); + + controllerRef.current?.seekToTimestamp( + selectedReview.start_time, + true + ); + }} + /> +
+ +
+ setScrubbing(scrubbing)} + /> +
+
+ ); +} diff --git a/web/src/views/events/MobileEventView.tsx b/web/src/views/events/MobileEventView.tsx index c48d5f4b0..d4ef788d5 100644 --- a/web/src/views/events/MobileEventView.tsx +++ b/web/src/views/events/MobileEventView.tsx @@ -3,72 +3,32 @@ import ActivityIndicator from "@/components/ui/activity-indicator"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { FrigateConfig } from "@/types/frigateConfig"; import { ReviewSegment, ReviewSeverity } from "@/types/review"; -import axios from "axios"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { MdCircle } from "react-icons/md"; import useSWR from "swr"; -import useSWRInfinite from "swr/infinite"; -const API_LIMIT = 250; - -export default function MobileEventView() { +type MobileEventViewProps = { + reviewPages?: ReviewSegment[][]; + relevantPreviews?: Preview[]; + reachedEnd: boolean; + isValidating: boolean; + loadNextPage: () => void; + markItemAsReviewed: (reviewId: string) => void; +}; +export default function MobileEventView({ + reviewPages, + relevantPreviews, + reachedEnd, + isValidating, + loadNextPage, + markItemAsReviewed, +}: MobileEventViewProps) { const { data: config } = useSWR("config"); const [severity, setSeverity] = useState("alert"); const contentRef = useRef(null); // review paging - const [after, setAfter] = useState(0); - useEffect(() => { - const now = new Date(); - now.setHours(now.getHours() - 24); - setAfter(now.getTime() / 1000); - - const intervalId: NodeJS.Timeout = setInterval(() => { - const now = new Date(); - now.setHours(now.getHours() - 24); - setAfter(now.getTime() / 1000); - }, 60000); - return () => clearInterval(intervalId); - }, [60000]); - - const reviewSearchParams = {}; - const reviewSegmentFetcher = useCallback((key: any) => { - const [path, params] = Array.isArray(key) ? key : [key, undefined]; - return axios.get(path, { params }).then((res) => res.data); - }, []); - - const getKey = useCallback( - (index: number, prevData: ReviewSegment[]) => { - if (index > 0) { - const lastDate = prevData[prevData.length - 1].start_time; - const pagedParams = reviewSearchParams - ? { before: lastDate, after: after, limit: API_LIMIT } - : { - ...reviewSearchParams, - before: lastDate, - after: after, - limit: API_LIMIT, - }; - return ["review", pagedParams]; - } - - const params = reviewSearchParams - ? { limit: API_LIMIT, after: after } - : { ...reviewSearchParams, limit: API_LIMIT, after: after }; - return ["review", params]; - }, - [reviewSearchParams] - ); - - const { - data: reviewPages, - mutate: updateSegments, - size, - setSize, - isValidating, - } = useSWRInfinite(getKey, reviewSegmentFetcher); - const reviewItems = useMemo(() => { const all: ReviewSegment[] = []; const alerts: ReviewSegment[] = []; @@ -101,11 +61,6 @@ export default function MobileEventView() { }; }, [reviewPages]); - const isDone = useMemo( - () => (reviewPages?.at(-1)?.length ?? 0) < API_LIMIT, - [reviewPages] - ); - const currentItems = useMemo(() => { const current = reviewItems[severity]; @@ -125,8 +80,8 @@ export default function MobileEventView() { if (pagingObserver.current) pagingObserver.current.disconnect(); try { pagingObserver.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !isDone) { - setSize(size + 1); + if (entries[0].isIntersecting && !reachedEnd) { + loadNextPage(); } }); if (node) pagingObserver.current.observe(node); @@ -134,7 +89,7 @@ export default function MobileEventView() { // no op } }, - [isValidating, isDone] + [isValidating, reachedEnd] ); const [minimap, setMinimap] = useState([]); @@ -199,47 +154,6 @@ export default function MobileEventView() { return data; }, [minimap]); - // review status - - const setReviewed = useCallback( - async (id: string) => { - const resp = await axios.post(`review/${id}/viewed`); - - if (resp.status == 200) { - updateSegments(); - } - }, - [updateSegments] - ); - - // preview videos - - const previewTimes = useMemo(() => { - if ( - !reviewPages || - reviewPages.length == 0 || - reviewPages.at(-1)!!.length == 0 - ) { - return undefined; - } - - const startDate = new Date(); - startDate.setMinutes(0, 0, 0); - - const endDate = new Date(reviewPages.at(-1)!!.at(-1)!!.end_time); - endDate.setHours(0, 0, 0, 0); - return { - start: startDate.getTime() / 1000, - end: endDate.getTime() / 1000, - }; - }, [reviewPages]); - const { data: allPreviews } = useSWR( - previewTimes - ? `preview/all/start/${previewTimes.start}/end/${previewTimes.end}` - : null, - { revalidateOnFocus: false } - ); - if (!config) { return ; } @@ -291,7 +205,7 @@ export default function MobileEventView() { {currentItems ? ( currentItems.map((value, segIdx) => { const lastRow = segIdx == reviewItems[severity].length - 1; - const relevantPreview = Object.values(allPreviews || []).find( + const relevantPreview = Object.values(relevantPreviews || []).find( (preview) => preview.camera == value.camera && preview.start < value.start_time && @@ -309,10 +223,10 @@ export default function MobileEventView() { review={value} relevantPreview={relevantPreview} autoPlayback={minimapBounds.end == value.start_time} - setReviewed={() => setReviewed(value.id)} + setReviewed={() => markItemAsReviewed(value.id)} /> - {lastRow && !isDone && } + {lastRow && !reachedEnd && } ); }) diff --git a/web/src/views/history/DesktopTimelineView.tsx b/web/src/views/history/DesktopTimelineView.tsx deleted file mode 100644 index 0cabd35ac..000000000 --- a/web/src/views/history/DesktopTimelineView.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import ActivityScrubber from "@/components/scrubber/ActivityScrubber"; -import ActivityIndicator from "@/components/ui/activity-indicator"; -import { FrigateConfig } from "@/types/frigateConfig"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import useSWR from "swr"; -import TimelineItemCard from "@/components/card/TimelineItemCard"; -import { getTimelineHoursForDay } from "@/utils/historyUtil"; -import { GraphDataPoint } from "@/types/graph"; -import TimelineGraph from "@/components/graph/TimelineGraph"; -import TimelineBar from "@/components/bar/TimelineBar"; -import DynamicVideoPlayer, { - DynamicVideoController, -} from "@/components/player/DynamicVideoPlayer"; - -type DesktopTimelineViewProps = { - timelineData: CardsData; - allPreviews: Preview[]; - initialPlayback: TimelinePlayback; -}; - -export default function DesktopTimelineView({ - timelineData, - allPreviews, - initialPlayback, -}: DesktopTimelineViewProps) { - const { data: config } = useSWR("config"); - const timezone = useMemo( - () => - config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, - [config] - ); - - const controllerRef = useRef(undefined); - const initialScrollRef = useRef(null); - - // handle scrolling to initial timeline item - useEffect(() => { - if (initialScrollRef.current != null) { - initialScrollRef.current.scrollIntoView(); - } - }, [initialScrollRef]); - - const cameraPreviews = useMemo(() => { - return allPreviews.filter((preview) => { - return preview.camera == initialPlayback.camera; - }); - }, []); - - const [timelineTime, setTimelineTime] = useState(0); - const timelineStack = useMemo( - () => - getTimelineHoursForDay( - initialPlayback.camera, - timelineData, - cameraPreviews, - initialPlayback.range.start + 60 - ), - [] - ); - - const [selectedPlaybackIdx, setSelectedPlaybackIdx] = useState( - timelineStack.playbackItems.findIndex((playback) => { - return ( - playback.range.start == initialPlayback.range.start && - playback.range.end == initialPlayback.range.end - ); - }) - ); - const selectedPlayback = useMemo( - () => timelineStack.playbackItems[selectedPlaybackIdx], - [selectedPlaybackIdx] - ); - - // handle moving to next clip - useEffect(() => { - if (!controllerRef.current) { - return; - } - - if (selectedPlaybackIdx > 0) { - controllerRef.current.onClipEndedEvent(() => { - console.log("setting to " + (selectedPlaybackIdx - 1)); - setSelectedPlaybackIdx(selectedPlaybackIdx - 1); - }); - } - }, [controllerRef, selectedPlaybackIdx]); - - const { data: activity } = useSWR( - [ - `${initialPlayback.camera}/recording/hourly/activity`, - { - after: timelineStack.start, - before: timelineStack.end, - timezone, - }, - ], - { revalidateOnFocus: false } - ); - - const timelineGraphData = useMemo(() => { - if (!activity) { - return {}; - } - - const graphData: { - [hour: string]: { objects: number[]; motion: GraphDataPoint[] }; - } = {}; - - Object.entries(activity).forEach(([hour, data]) => { - const objects: number[] = []; - const motion: GraphDataPoint[] = []; - - data.forEach((seg, idx) => { - if (seg.hasObjects) { - objects.push(idx); - } - - motion.push({ - x: new Date(seg.date * 1000), - y: seg.count, - }); - }); - - graphData[hour] = { objects, motion }; - }); - - return graphData; - }, [activity]); - - if (!config) { - return ; - } - - return ( -
-
- { - controllerRef.current = controller; - controllerRef.current.onPlayerTimeUpdate((timestamp: number) => { - setTimelineTime(timestamp); - }); - - if (initialPlayback.timelineItems.length > 0) { - controllerRef.current?.seekToTimestamp( - selectedPlayback.timelineItems[0].timestamp, - true - ); - } - }} - /> -
-
- {selectedPlayback.timelineItems.map((timeline) => { - return ( - { - controllerRef.current?.seekToTimelineItem(timeline); - }} - /> - ); - })} -
-
-
-
-
- {timelineStack.playbackItems.map((timeline, tIdx) => { - const isInitiallySelected = - initialPlayback.range.start == timeline.range.start; - const isSelected = - timeline.range.start == selectedPlayback.range.start; - const graphData = timelineGraphData[timeline.range.start]; - const start = new Date(timeline.range.start * 1000); - const end = new Date(timeline.range.end * 1000); - - return ( -
- {isSelected ? ( -
- { - controllerRef.current?.scrubToTimestamp( - data.time.getTime() / 1000 - ); - setTimelineTime(data.time.getTime() / 1000); - }} - timechangedHandler={(data) => { - controllerRef.current?.seekToTimestamp( - data.time.getTime() / 1000, - true - ); - }} - /> - {isSelected && graphData && ( -
- -
- )} -
- ) : ( - { - setSelectedPlaybackIdx(tIdx); - - let startTs; - if (timeline.timelineItems.length > 0) { - startTs = selectedPlayback.timelineItems[0].timestamp; - } else { - startTs = timeline.range.start; - } - - controllerRef.current?.seekToTimestamp(startTs, true); - }} - /> - )} -
- ); - })} -
-
-
- ); -} diff --git a/web/src/views/history/HistoryCardView.tsx b/web/src/views/history/HistoryCardView.tsx deleted file mode 100644 index 36238a43f..000000000 --- a/web/src/views/history/HistoryCardView.tsx +++ /dev/null @@ -1,149 +0,0 @@ -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, - getRangeForTimestamp, -} 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, - range: getRangeForTimestamp(timeline.time), - 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/MobileTimelineView.tsx b/web/src/views/history/MobileTimelineView.tsx deleted file mode 100644 index 48a46dd3d..000000000 --- a/web/src/views/history/MobileTimelineView.tsx +++ /dev/null @@ -1,137 +0,0 @@ -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 { useMemo, useRef, useState } from "react"; -import useSWR from "swr"; -import DynamicVideoPlayer, { - DynamicVideoController, -} from "@/components/player/DynamicVideoPlayer"; - -type MobileTimelineViewProps = { - playback: TimelinePlayback; -}; - -export default function MobileTimelineView({ - playback, -}: MobileTimelineViewProps) { - const { data: config } = useSWR("config"); - - const controllerRef = useRef(undefined); - - const [timelineTime, setTimelineTime] = useState( - playback.timelineItems.length > 0 - ? playback.timelineItems[0].timestamp - : playback.range.start - ); - - const recordingParams = useMemo(() => { - return { - before: playback.range.end, - after: playback.range.start, - }; - }, [playback]); - const { data: recordings } = useSWR( - playback ? [`${playback.camera}/recordings`, recordingParams] : null, - { revalidateOnFocus: false } - ); - - if (!config || !recordings) { - return ; - } - - return ( -
- { - controllerRef.current = controller; - controllerRef.current.onPlayerTimeUpdate((timestamp: number) => { - setTimelineTime(timestamp); - }); - - if (playback.timelineItems.length > 0) { - controllerRef.current?.seekToTimestamp( - playback.timelineItems[0].timestamp, - true - ); - } - }} - /> -
- {playback != undefined && ( - { - controllerRef.current?.scrubToTimestamp( - data.time.getTime() / 1000 - ); - setTimelineTime(data.time.getTime() / 1000); - }} - timechangedHandler={(data) => { - controllerRef.current?.seekToTimestamp( - data.time.getTime() / 1000, - true - ); - }} - selectHandler={(data) => { - if (data.items.length > 0) { - const selected = parseFloat(data.items[0].split("-")[0]); - - const timeline = playback.timelineItems.find( - (timeline) => timeline.timestamp == selected - ); - - if (timeline) { - controllerRef.current?.seekToTimelineItem(timeline); - } - } - }} - /> - )} -
-
- ); -} - -function timelineItemsToScrubber(items: Timeline[]): ScrubberItem[] { - return items.map((item, idx) => { - return { - id: `${item.timestamp}-${idx}`, - 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; -}