From 849f343b259c6f7c3a52d1210c1ccd73a6e61607 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 26 Feb 2024 15:08:55 -0700 Subject: [PATCH] Reduce rerenders on some components (#10068) * Remove direct callback * don't rerender for callback * Move websocket to top level events * Be smarter about updating timeago * Cleanup --- web/src/components/dynamic/TimeAgo.tsx | 59 ++++++++++++++----- .../player/PreviewThumbnailPlayer.tsx | 30 +++++++--- web/src/pages/Events.tsx | 35 ++++++++++- web/src/views/events/DesktopEventView.tsx | 28 ++------- web/src/views/events/MobileEventView.tsx | 38 +++++++++++- 5 files changed, 141 insertions(+), 49 deletions(-) diff --git a/web/src/components/dynamic/TimeAgo.tsx b/web/src/components/dynamic/TimeAgo.tsx index 3dd510cef..df2b70285 100644 --- a/web/src/components/dynamic/TimeAgo.tsx +++ b/web/src/components/dynamic/TimeAgo.tsx @@ -8,7 +8,7 @@ interface IProp { /** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */ dense?: boolean; /** OPTIONAL: set custom refresh interval in milliseconds, default 1000 (1 sec) */ - refreshInterval?: number; + manualRefreshInterval?: number; } type TimeUnit = { @@ -17,24 +17,28 @@ type TimeUnit = { value: number; }; -const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): string => { - if (typeof time !== 'number' || time < 0) return 'Invalid Time Provided'; +const timeAgo = ({ + time, + currentTime = new Date(), + dense = false, +}: IProp): string => { + if (typeof time !== "number" || time < 0) return "Invalid Time Provided"; const pastTime: Date = new Date(time); const elapsedTime: number = currentTime.getTime() - pastTime.getTime(); const timeUnits: TimeUnit[] = [ - { unit: 'yr', full: 'year', value: 31536000 }, - { unit: 'mo', full: 'month', value: 0 }, - { unit: 'd', full: 'day', value: 86400 }, - { unit: 'h', full: 'hour', value: 3600 }, - { unit: 'm', full: 'minute', value: 60 }, - { unit: 's', full: 'second', value: 1 }, + { unit: "yr", full: "year", value: 31536000 }, + { unit: "mo", full: "month", value: 0 }, + { unit: "d", full: "day", value: 86400 }, + { unit: "h", full: "hour", value: 3600 }, + { unit: "m", full: "minute", value: 60 }, + { unit: "s", full: "second", value: 1 }, ]; const elapsed: number = elapsedTime / 1000; if (elapsed < 10) { - return 'just now'; + return "just now"; } for (let i = 0; i < timeUnits.length; i++) { @@ -48,7 +52,8 @@ const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): stri const currentMonth = currentTime.getUTCMonth(); const currentYear = currentTime.getUTCFullYear(); - let monthDiff = (currentYear - pastYear) * 12 + (currentMonth - pastMonth); + let monthDiff = + (currentYear - pastYear) * 12 + (currentMonth - pastMonth); // check if the time provided is the previous month but not exceeded 1 month ago. if (currentTime.getUTCDate() < pastTime.getUTCDate()) { @@ -57,18 +62,37 @@ const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): stri if (monthDiff > 0) { const unitAmount = monthDiff; - return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`; + return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? "" : "s"} ago`; } } else if (elapsed >= timeUnits[i].value) { const unitAmount: number = Math.floor(elapsed / timeUnits[i].value); - return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`; + return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? "" : "s"} ago`; } } - return 'Invalid Time'; + return "Invalid Time"; }; -const TimeAgo: FunctionComponent = ({ refreshInterval = 1000, ...rest }): JSX.Element => { +const TimeAgo: FunctionComponent = ({ + time, + manualRefreshInterval, + ...rest +}): JSX.Element => { const [currentTime, setCurrentTime] = useState(new Date()); + const refreshInterval = useMemo(() => { + if (manualRefreshInterval) { + return manualRefreshInterval; + } + + const currentTs = currentTime.getTime() / 1000; + if (currentTs - time < 60) { + return 1000; // refresh every second + } else if (currentTs - time < 3600) { + return 60000; // refresh every minute + } else { + return 3600000; // refresh every hour + } + }, [currentTime, manualRefreshInterval]); + useEffect(() => { const intervalId: NodeJS.Timeout = setInterval(() => { setCurrentTime(new Date()); @@ -76,7 +100,10 @@ const TimeAgo: FunctionComponent = ({ refreshInterval = 1000, ...rest }): return () => clearInterval(intervalId); }, [refreshInterval]); - const timeAgoValue = useMemo(() => timeAgo({ currentTime, ...rest }), [currentTime, rest]); + const timeAgoValue = useMemo( + () => timeAgo({ time, currentTime, ...rest }), + [currentTime, rest] + ); return {timeAgoValue}; }; diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 7b5ffac64..2bfc3b4f2 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -26,8 +26,8 @@ type PreviewPlayerProps = { review: ReviewSegment; relevantPreview?: Preview; autoPlayback?: boolean; - setReviewed?: () => void; - onClick?: () => void; + setReviewed?: (reviewId: string) => void; + onClick?: (reviewId: string) => void; }; type Preview = { @@ -53,6 +53,22 @@ export default function PreviewThumbnailPlayer({ const [progress, setProgress] = useState(0); const [imgRef, imgLoaded, onImgLoad] = useImageLoaded(); + // interaction + + const handleOnClick = useCallback(() => { + if (onClick) { + onClick(review.id); + } + }, [review, onClick]); + + const handleSetReviewed = useCallback(() => { + if (setReviewed) { + setReviewed(review.id); + } + }, [review, setReviewed]); + + // playback + const playingBack = useMemo(() => playback, [playback, autoPlayback]); useEffect(() => { @@ -110,7 +126,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} + onClick={handleOnClick} > {playingBack && (
@@ -118,7 +134,7 @@ export default function PreviewThumbnailPlayer({ review={review} relevantPreview={relevantPreview} setProgress={setProgress} - setReviewed={setReviewed} + setReviewed={handleSetReviewed} />
)} @@ -184,7 +200,7 @@ export default function PreviewThumbnailPlayer({ )} - + ); } @@ -321,7 +337,7 @@ const MIN_LOAD_TIMEOUT_MS = 200; type InProgressPreviewProps = { review: ReviewSegment; setProgress?: (progress: number) => void; - setReviewed?: () => void; + setReviewed?: (reviewId: string) => void; }; function InProgressPreview({ review, @@ -355,7 +371,7 @@ function InProgressPreview({ } if (setReviewed && key == Math.floor(previewFrames.length / 2)) { - setReviewed(); + setReviewed(review.id); } setKey(key + 1); diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 4778cbeb1..62a18c504 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -1,3 +1,4 @@ +import { useFrigateEvents } from "@/api/ws"; import useApiFilter from "@/hooks/use-api-filter"; import useOverlayState from "@/hooks/use-overlay-state"; import { ReviewFilter, ReviewSegment } from "@/types/review"; @@ -5,7 +6,7 @@ import DesktopEventView from "@/views/events/DesktopEventView"; import DesktopRecordingView from "@/views/events/DesktopRecordingView"; import MobileEventView from "@/views/events/MobileEventView"; import axios from "axios"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { isMobile } from "react-device-detect"; import useSWR from "swr"; import useSWRInfinite from "swr/infinite"; @@ -92,6 +93,10 @@ export default function Events() { [reviewPages] ); + const onLoadNextPage = useCallback(() => { + setSize(size + 1); + }, [size]); + // preview videos const previewTimes = useMemo(() => { @@ -189,6 +194,25 @@ export default function Events() { }; }, [selectedReviewId, reviewPages]); + // review updates + + const { payload: eventUpdate } = useFrigateEvents(); + const [hasUpdate, setHasUpdate] = useState(false); + useEffect(() => { + if (!eventUpdate) { + return; + } + + // 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 (selectedData) { return ( setSize(size + 1)} + hasUpdate={hasUpdate} + setHasUpdate={setHasUpdate} + loadNextPage={onLoadNextPage} markItemAsReviewed={markItemAsReviewed} + pullLatestData={updateSegments} /> ); } @@ -219,7 +246,9 @@ export default function Events() { reachedEnd={isDone} isValidating={isValidating} filter={reviewFilter} - loadNextPage={() => setSize(size + 1)} + hasUpdate={hasUpdate} + setHasUpdate={setHasUpdate} + loadNextPage={onLoadNextPage} markItemAsReviewed={markItemAsReviewed} onSelectReview={setSelectedReviewId} pullLatestData={updateSegments} diff --git a/web/src/views/events/DesktopEventView.tsx b/web/src/views/events/DesktopEventView.tsx index 5008ad1bb..666cce103 100644 --- a/web/src/views/events/DesktopEventView.tsx +++ b/web/src/views/events/DesktopEventView.tsx @@ -1,4 +1,3 @@ -import { useFrigateEvents } from "@/api/ws"; import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup"; import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; import EventReviewTimeline from "@/components/timeline/EventReviewTimeline"; @@ -19,6 +18,8 @@ type DesktopEventViewProps = { reachedEnd: boolean; isValidating: boolean; filter?: ReviewFilter; + hasUpdate: boolean; + setHasUpdate: (hasUpdated: boolean) => void; loadNextPage: () => void; markItemAsReviewed: (reviewId: string) => void; onSelectReview: (reviewId: string) => void; @@ -32,6 +33,8 @@ export default function DesktopEventView({ reachedEnd, isValidating, filter, + hasUpdate, + setHasUpdate, loadNextPage, markItemAsReviewed, onSelectReview, @@ -177,25 +180,6 @@ export default function DesktopEventView({ return data; }, [minimap]); - // new data alert - - const { payload: eventUpdate } = useFrigateEvents(); - const [hasUpdate, setHasUpdate] = useState(false); - useEffect(() => { - if (!eventUpdate) { - return; - } - - // 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 ; } @@ -306,8 +290,8 @@ export default function DesktopEventView({ markItemAsReviewed(value.id)} - onClick={() => onSelectReview(value.id)} + setReviewed={markItemAsReviewed} + onClick={onSelectReview} /> {lastRow && !reachedEnd && } diff --git a/web/src/views/events/MobileEventView.tsx b/web/src/views/events/MobileEventView.tsx index d4ef788d5..b476808d7 100644 --- a/web/src/views/events/MobileEventView.tsx +++ b/web/src/views/events/MobileEventView.tsx @@ -1,9 +1,11 @@ import PreviewThumbnailPlayer from "@/components/player/PreviewThumbnailPlayer"; import ActivityIndicator from "@/components/ui/activity-indicator"; +import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { FrigateConfig } from "@/types/frigateConfig"; import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LuRefreshCcw } from "react-icons/lu"; import { MdCircle } from "react-icons/md"; import useSWR from "swr"; @@ -12,16 +14,22 @@ type MobileEventViewProps = { relevantPreviews?: Preview[]; reachedEnd: boolean; isValidating: boolean; + hasUpdate: boolean; + setHasUpdate: (hasUpdated: boolean) => void; loadNextPage: () => void; markItemAsReviewed: (reviewId: string) => void; + pullLatestData: () => void; }; export default function MobileEventView({ reviewPages, relevantPreviews, reachedEnd, isValidating, + hasUpdate, + setHasUpdate, loadNextPage, markItemAsReviewed, + pullLatestData, }: MobileEventViewProps) { const { data: config } = useSWR("config"); const [severity, setSeverity] = useState("alert"); @@ -198,6 +206,34 @@ export default function MobileEventView({ + {hasUpdate && ( +
+
+ +
+
+ )} +
markItemAsReviewed(value.id)} + setReviewed={markItemAsReviewed} />
{lastRow && !reachedEnd && }