From 6c5615ca4329b7df6ad137e589fceb1385324cf4 Mon Sep 17 00:00:00 2001 From: Nick Mowen Date: Sun, 31 Dec 2023 11:40:44 -0700 Subject: [PATCH] Separate mobile and desktop views and don't rerender --- web/src/pages/History.tsx | 14 +- ...melineView.tsx => DesktopTimelineView.tsx} | 289 ++--------------- web/src/views/history/MobileTimelineView.tsx | 298 ++++++++++++++++++ 3 files changed, 328 insertions(+), 273 deletions(-) rename web/src/views/history/{HistoryTimelineView.tsx => DesktopTimelineView.tsx} (55%) create mode 100644 web/src/views/history/MobileTimelineView.tsx diff --git a/web/src/pages/History.tsx b/web/src/pages/History.tsx index 8469a4d92..96f19f520 100644 --- a/web/src/pages/History.tsx +++ b/web/src/pages/History.tsx @@ -25,6 +25,8 @@ 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; @@ -245,14 +247,7 @@ function TimelineViewer({ if (isMobile) { return playback != undefined ? (
- {timelineData && ( - - )} + {timelineData && }
) : null; } @@ -261,11 +256,10 @@ function TimelineViewer({ onClose()}> {timelineData && playback && ( - )} diff --git a/web/src/views/history/HistoryTimelineView.tsx b/web/src/views/history/DesktopTimelineView.tsx similarity index 55% rename from web/src/views/history/HistoryTimelineView.tsx rename to web/src/views/history/DesktopTimelineView.tsx index e74927659..46f0fc37e 100644 --- a/web/src/views/history/HistoryTimelineView.tsx +++ b/web/src/views/history/DesktopTimelineView.tsx @@ -1,41 +1,27 @@ import { useApiHost } from "@/api"; import TimelineEventOverlay from "@/components/overlay/TimelineDataOverlay"; import VideoPlayer from "@/components/player/VideoPlayer"; -import ActivityScrubber, { - ScrubberItem, -} from "@/components/scrubber/ActivityScrubber"; +import ActivityScrubber 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 React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; + +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 HistoryTimelineViewProps = { +type DesktopTimelineViewProps = { timelineData: CardsData; allPreviews: Preview[]; initialPlayback: TimelinePlayback; - isMobile: boolean; }; -export default function HistoryTimelineView({ +export default function DesktopTimelineView({ timelineData, allPreviews, initialPlayback, - isMobile, -}: HistoryTimelineViewProps) { +}: DesktopTimelineViewProps) { const apiHost = useApiHost(); const { data: config } = useSWR("config"); const timezone = useMemo( @@ -185,84 +171,18 @@ export default function HistoryTimelineView({ } }, [timeToSeek, seeking]); - if (!config || !recordings) { - return ; - } + // handle loading main playback when selected hour changes + useEffect(() => { + if (!playerRef.current) { + return; + } - if (isMobile) { - return ( - - ); - } + playerRef.current.src({ + src: playbackUri, + type: "application/vnd.apple.mpegurl", + }); + }, [playerRef, selectedPlayback]); - return ( - - ); -} - -type DesktopViewProps = { - config: FrigateConfig; - playerRef: React.MutableRefObject; - previewRef: React.MutableRefObject; - timelineData: CardsData; - allPreviews: Preview[]; - selectedPlayback: TimelinePlayback; - setSelectedPlayback: (timeline: TimelinePlayback) => void; - playbackUri: string; - timelineTime: number; - scrubbing: boolean; - focusedItem: Timeline | undefined; - setSeeking: (seeking: boolean) => void; - onSelectItem: (timeline: Timeline | undefined) => void; - onScrubTime: ({ time }: { time: Date }) => void; - onStopScrubbing: ({ time }: { time: Date }) => void; -}; -function DesktopView({ - config, - playerRef, - previewRef, - timelineData, - allPreviews, - selectedPlayback, - setSelectedPlayback, - playbackUri, - timelineTime, - scrubbing, - focusedItem, - setSeeking, - onSelectItem, - onScrubTime, - onStopScrubbing, -}: DesktopViewProps) { const timelineStack = useMemo( () => getTimelineHoursForDay( @@ -274,6 +194,10 @@ function DesktopView({ [] ); + if (!config) { + return ; + } + return (
@@ -290,12 +214,6 @@ function DesktopView({ options={{ preload: "auto", autoplay: true, - sources: [ - { - src: playbackUri, - type: "application/vnd.apple.mpegurl", - }, - ], }} seekOptions={{ forward: 10, backward: 5 }} onReady={(player) => { @@ -311,12 +229,12 @@ function DesktopView({ playerRef.current = undefined; }} > - {config && focusedItem ? ( + {focusedItem && ( - ) : undefined} + )}
{selectedPlayback.relevantPreview && ( @@ -367,9 +285,11 @@ function DesktopView({ timeline.range.start == selectedPlayback.range.start; return ( -
+
); } - -type MobileViewProps = { - config: FrigateConfig; - playerRef: React.MutableRefObject; - previewRef: React.MutableRefObject; - playback: TimelinePlayback; - playbackUri: string; - timelineTime: number; - scrubbing: boolean; - focusedItem: Timeline | undefined; - setSeeking: (seeking: boolean) => void; - onSelectItem: (timeline: Timeline | undefined) => void; - onScrubTime: ({ time }: { time: Date }) => void; - onStopScrubbing: ({ time }: { time: Date }) => void; -}; -function MobileView({ - config, - playerRef, - previewRef, - playback, - playbackUri, - timelineTime, - scrubbing, - focusedItem, - setSeeking, - onSelectItem, - onScrubTime, - onStopScrubbing, -}: MobileViewProps) { - return ( -
- <> -
- { - playerRef.current = player; - player.currentTime(timelineTime - playback.range.start); - player.on("playing", () => { - onSelectItem(undefined); - }); - }} - onDispose={() => { - playerRef.current = undefined; - }} - > - {config && focusedItem ? ( - - ) : undefined} - -
- {playback.relevantPreview && ( -
- { - previewRef.current = player; - player.on("seeked", () => setSeeking(false)); - }} - onDispose={() => { - previewRef.current = undefined; - }} - /> -
- )} - -
- {playback != undefined && ( - { - if (data.items.length > 0) { - const selected = data.items[0]; - onSelectItem( - playback.timelineItems.find( - (timeline) => timeline.timestamp == selected - ) - ); - } - }} - /> - )} -
-
- ); -} - -function timelineItemsToScrubber(items: Timeline[]): ScrubberItem[] { - return items.map((item) => { - return { - id: item.timestamp, - 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; -} diff --git a/web/src/views/history/MobileTimelineView.tsx b/web/src/views/history/MobileTimelineView.tsx new file mode 100644 index 000000000..bc37c232c --- /dev/null +++ b/web/src/views/history/MobileTimelineView.tsx @@ -0,0 +1,298 @@ +import { useApiHost } from "@/api"; +import TimelineEventOverlay from "@/components/overlay/TimelineDataOverlay"; +import VideoPlayer from "@/components/player/VideoPlayer"; +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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import useSWR from "swr"; +import Player from "video.js/dist/types/player"; + +type MobileTimelineViewProps = { + playback: TimelinePlayback; +}; + +export default function MobileTimelineView({ + playback, +}: MobileTimelineViewProps) { + const apiHost = useApiHost(); + const { data: config } = useSWR("config"); + const timezone = useMemo( + () => + config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, + [config] + ); + + 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 annotationOffset = useMemo(() => { + if (!config) { + return 0; + } + + return ( + (config.cameras[playback.camera]?.detect?.annotation_offset || 0) / 1000 + ); + }, [config]); + + const timelineTime = useMemo(() => { + if (!playback || playback.timelineItems.length == 0) { + return 0; + } + + return playback.timelineItems.at(0)!!.timestamp; + }, [playback]); + + 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 } + ); + + const playbackUri = useMemo(() => { + if (!playback) { + return ""; + } + + 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`; + }, [playback]); + + 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] + ); + + const onScrubTime = useCallback( + (data: { time: Date }) => { + if (!playback.relevantPreview) { + return; + } + + if (playerRef.current?.paused() == false) { + setScrubbing(true); + playerRef.current?.pause(); + } + + const seekTimestamp = data.time.getTime() / 1000; + const seekTime = seekTimestamp - playback.relevantPreview.start; + console.log( + "seeking to " + + seekTime + + " comparing " + + new Date(seekTimestamp * 1000) + + " - " + + new Date(playback.relevantPreview.start * 1000) + ); + setTimeToSeek(Math.round(seekTime)); + }, + [scrubbing, playerRef, playback] + ); + + const onStopScrubbing = useCallback( + (data: { time: Date }) => { + const playbackTime = data.time.getTime() / 1000; + playerRef.current?.currentTime(playbackTime - playback.range.start); + setScrubbing(false); + playerRef.current?.play(); + }, + [playback, 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]); + + if (!config || !recordings) { + return ; + } + + return ( +
+ <> +
+ { + playerRef.current = player; + player.currentTime(timelineTime - playback.range.start); + player.on("playing", () => { + onSelectItem(undefined); + }); + }} + onDispose={() => { + playerRef.current = undefined; + }} + > + {config && focusedItem ? ( + + ) : undefined} + +
+ {playback.relevantPreview && ( +
+ { + previewRef.current = player; + player.on("seeked", () => setSeeking(false)); + }} + onDispose={() => { + previewRef.current = undefined; + }} + /> +
+ )} + +
+ {playback != undefined && ( + { + if (data.items.length > 0) { + const selected = data.items[0]; + onSelectItem( + playback.timelineItems.find( + (timeline) => timeline.timestamp == selected + ) + ); + } + }} + /> + )} +
+
+ ); +} + +function timelineItemsToScrubber(items: Timeline[]): ScrubberItem[] { + return items.map((item) => { + return { + id: item.timestamp, + 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; +}