From fbe58652d51ddc8c66b075401f7583d865190306 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Wed, 13 Dec 2023 20:15:28 -0700 Subject: [PATCH] Improve review grid (#8953) * Use constant aspect ratio for review grid * Add infinite scrolling * Don't have horizontal scrolling * Handle autoplay for mobile * Load more efficiently --- web-new/src/components/card/HistoryCard.tsx | 12 +- .../player/PreviewThumbnailPlayer.tsx | 167 +++++++---- web-new/src/components/player/VideoPlayer.tsx | 2 +- web-new/src/pages/History.tsx | 265 +++++++++++------- web-new/src/utils/dateUtil.ts | 4 +- web-new/tailwind.config.js | 4 + web-new/vite.config.ts | 12 +- 7 files changed, 305 insertions(+), 161 deletions(-) diff --git a/web-new/src/components/card/HistoryCard.tsx b/web-new/src/components/card/HistoryCard.tsx index 98c826435..ddf77e47f 100644 --- a/web-new/src/components/card/HistoryCard.tsx +++ b/web-new/src/components/card/HistoryCard.tsx @@ -21,12 +21,14 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; type HistoryCardProps = { timeline: Card; - allPreviews?: Preview[]; + relevantPreview?: Preview; + shouldAutoPlay: boolean; }; export default function HistoryCard({ - allPreviews, + relevantPreview, timeline, + shouldAutoPlay, }: HistoryCardProps) { const { data: config } = useSWR("config"); @@ -35,11 +37,13 @@ export default function HistoryCard({ } return ( - +
diff --git a/web-new/src/components/player/PreviewThumbnailPlayer.tsx b/web-new/src/components/player/PreviewThumbnailPlayer.tsx index abd4b6533..725e83376 100644 --- a/web-new/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web-new/src/components/player/PreviewThumbnailPlayer.tsx @@ -1,65 +1,122 @@ import { FrigateConfig } from "@/types/frigateConfig"; import VideoPlayer from "./VideoPlayer"; import useSWR from "swr"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useRef } from "react"; import { useApiHost } from "@/api"; import Player from "video.js/dist/types/player"; +import { AspectRatio } from "../ui/aspect-ratio"; type PreviewPlayerProps = { - camera: string, - allPreviews: Preview[], - startTs: number, -} + camera: string; + relevantPreview?: Preview; + startTs: number; + eventId: string; + shouldAutoPlay: boolean; +}; type Preview = { - camera: string, - src: string, - type: string, - start: number, - end: number, -} + camera: string; + src: string; + type: string; + start: number; + end: number; +}; -export default function PreviewThumbnailPlayer({ camera, allPreviews, startTs }: PreviewPlayerProps) { - const { data: config } = useSWR('config'); - const playerRef = useRef(null); - const apiHost = useApiHost(); +export default function PreviewThumbnailPlayer({ + camera, + relevantPreview, + startTs, + eventId, + shouldAutoPlay, +}: PreviewPlayerProps) { + const { data: config } = useSWR("config"); + const playerRef = useRef(null); + const apiHost = useApiHost(); - const relevantPreview = useMemo(() => { - return Object.values(allPreviews || []).find( - (preview) => preview.camera == camera && preview.start < startTs && preview.end > startTs - ); - }, [allPreviews, camera, startTs]); + const onPlayback = useCallback( + (isHovered: Boolean) => { + if (!relevantPreview || !playerRef.current) { + return; + } - const onHover = useCallback((isHovered: Boolean) => { - if (!relevantPreview || !playerRef.current) { - return; - } + if (isHovered) { + playerRef.current.play(); + } else { + playerRef.current.pause(); + playerRef.current.currentTime(startTs - relevantPreview.start); + } + }, + [relevantPreview, startTs] + ); - if (isHovered) { - playerRef.current.play(); - } else { - playerRef.current.pause(); - playerRef.current.currentTime(startTs - relevantPreview.start); - } - }, - [relevantPreview, startTs] - ); + const observer = useRef(); + const inViewRef = useCallback( + (node: HTMLElement | null) => { + if (!shouldAutoPlay || observer.current) { + return; + } - if (!relevantPreview) { + try { + observer.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + onPlayback(true); + } else { + onPlayback(false); + } + }, + { threshold: 1.0 } + ); + if (node) observer.current.observe(node); + } catch (e) { + // no op + } + }, + [observer, onPlayback] + ); + + if (!relevantPreview) { + if (isCurrentHour(startTs)) { return ( - + + + ); } return ( -
onHover(true)} - onMouseLeave={() => onHover(false)} + + + + ); + } + + return ( + onPlayback(true)} + onMouseLeave={() => onPlayback(false)} + > +
- ); +
+ ); +} + +function isCurrentHour(timestamp: number) { + const now = new Date(); + now.setMinutes(0, 0, 0); + return timestamp > now.getTime() / 1000; +} + +function getPreviewWidth(camera: string, config: FrigateConfig) { + const detect = config.cameras[camera].detect; + + if (detect.width / detect.height < 1.4) { + return "w-[208px]"; } -function getThumbWidth(camera: string, config: FrigateConfig) { -const detect = config.cameras[camera].detect; -if (detect.width / detect.height > 2) { - return 'w-[320px]'; + return "w-full"; } - -if (detect.width / detect.height < 1.4) { - return 'w-[200px]'; -} - -return 'w-[240px]'; -} \ No newline at end of file diff --git a/web-new/src/components/player/VideoPlayer.tsx b/web-new/src/components/player/VideoPlayer.tsx index 7e8cab137..50bf3ceae 100644 --- a/web-new/src/components/player/VideoPlayer.tsx +++ b/web-new/src/components/player/VideoPlayer.tsx @@ -44,7 +44,7 @@ export default function VideoPlayer({ children, options, seekOptions = {forward: videoElement.classList.add('small-player'); videoElement.classList.add('video-js'); videoElement.classList.add('vjs-default-skin'); - videoRef.current.appendChild(videoElement); + videoRef.current?.appendChild(videoElement); const player = playerRef.current = videojs(videoElement, { ...defaultOptions, ...options }, () => { onReady && onReady(player); diff --git a/web-new/src/pages/History.tsx b/web-new/src/pages/History.tsx index 9b98d8b52..f97072836 100644 --- a/web-new/src/pages/History.tsx +++ b/web-new/src/pages/History.tsx @@ -1,11 +1,14 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, 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 HistoryCard from "@/components/card/HistoryCard"; -import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; +import axios from "axios"; + +const API_LIMIT = 100; function History() { const { data: config } = useSWR("config"); @@ -14,13 +17,35 @@ function History() { config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config] ); - const { data: hourlyTimeline } = useSWR([ - "timeline/hourly", - { timezone }, - ]); + 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 = { before: lastDate, timezone, limit: API_LIMIT }; + return ["timeline/hourly", pagedParams]; + } + + return ["timeline/hourly", { timezone, limit: API_LIMIT }]; + }, []); + + const shouldAutoPlay = useMemo(() => { + return window.innerWidth < 480; + }, []); + + const { + data: timelinePages, + mutate, + size, + setSize, + isValidating, + } = useSWRInfinite(getKey, timelineFetcher); const { data: allPreviews } = useSWR( - `preview/all/start/${hourlyTimeline?.start || 0}/end/${ - hourlyTimeline?.end || 0 + `preview/all/start/${(timelinePages ?? [])?.at(0)?.start ?? 0}/end/${ + (timelinePages ?? [])?.at(-1)?.end ?? 0 }`, { revalidateOnFocus: false } ); @@ -30,84 +55,113 @@ function History() { ); const timelineCards: CardsData | never[] = useMemo(() => { - if (!hourlyTimeline) { + if (!timelinePages) { return []; } const cards: CardsData = {}; - Object.keys(hourlyTimeline["hours"]) - .reverse() - .forEach((hour) => { - const day = new Date(parseInt(hour) * 1000); - day.setHours(0, 0, 0, 0); - const dayKey = (day.getTime() / 1000).toString(); - const source_to_types: { [key: string]: string[] } = {}; - Object.values(hourlyTimeline["hours"][hour]).forEach((i) => { - const time = new Date(i.timestamp * 1000); - time.setSeconds(0); - time.setMilliseconds(0); - const key = `${i.source_id}-${time.getMinutes()}`; - if (key in source_to_types) { - source_to_types[key].push(i.class_type); - } else { - source_to_types[key] = [i.class_type]; - } - }); - - if (!Object.keys(cards).includes(dayKey)) { - cards[dayKey] = {}; - } - cards[dayKey][hour] = {}; - Object.values(hourlyTimeline["hours"][hour]).forEach((i) => { - const time = new Date(i.timestamp * 1000); - const key = `${i.camera}-${time.getMinutes()}`; - - // 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; - if (detailLevel == "normal") { - if ( - source_to_types[`${i.source_id}-${time.getMinutes()}`].length > - 1 && - ["active", "attribute", "gone", "stationary", "visible"].includes( - i.class_type - ) - ) { - add = false; - } - } else if (detailLevel == "extra") { - if ( - source_to_types[`${i.source_id}-${time.getMinutes()}`].length > - 1 && - i.class_type in ["attribute", "gone", "visible"] - ) { - add = false; - } - } - - if (add) { - if (key in cards[dayKey][hour]) { - cards[dayKey][hour][key].entries.push(i); + timelinePages.forEach((hourlyTimeline) => { + Object.keys(hourlyTimeline["hours"]) + .reverse() + .forEach((hour) => { + const day = new Date(parseInt(hour) * 1000); + day.setHours(0, 0, 0, 0); + const dayKey = (day.getTime() / 1000).toString(); + const source_to_types: { [key: string]: string[] } = {}; + Object.values(hourlyTimeline["hours"][hour]).forEach((i) => { + const time = new Date(i.timestamp * 1000); + time.setSeconds(0); + time.setMilliseconds(0); + const key = `${i.source_id}-${time.getMinutes()}`; + if (key in source_to_types) { + source_to_types[key].push(i.class_type); } else { - cards[dayKey][hour][key] = { - camera: i.camera, - time: time.getTime() / 1000, - entries: [i], - }; + source_to_types[key] = [i.class_type]; } + }); + + if (!Object.keys(cards).includes(dayKey)) { + cards[dayKey] = {}; } + cards[dayKey][hour] = {}; + Object.values(hourlyTimeline["hours"][hour]).forEach((i) => { + const time = new Date(i.timestamp * 1000); + const key = `${i.camera}-${time.getMinutes()}`; + + // 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; + if (detailLevel == "normal") { + if ( + source_to_types[`${i.source_id}-${time.getMinutes()}`].length > + 1 && + [ + "active", + "attribute", + "gone", + "stationary", + "visible", + ].includes(i.class_type) + ) { + add = false; + } + } else if (detailLevel == "extra") { + if ( + source_to_types[`${i.source_id}-${time.getMinutes()}`].length > + 1 && + i.class_type in ["attribute", "gone", "visible"] + ) { + add = false; + } + } + + if (add) { + if (key in cards[dayKey][hour]) { + cards[dayKey][hour][key].entries.push(i); + } else { + cards[dayKey][hour][key] = { + camera: i.camera, + time: time.getTime() / 1000, + entries: [i], + }; + } + } + }); }); - }); + }); return cards; - }, [detailLevel, hourlyTimeline]); + }, [detailLevel, timelinePages]); - if (!config || !timelineCards) { + const isDone = + (timelinePages?.[timelinePages.length - 1]?.count ?? 0) < API_LIMIT; + + // 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) { + setSize(size + 1); + } + }); + if (node) observer.current.observe(node); + } catch (e) { + // no op + } + }, + [size, setSize, isValidating, isDone] + ); + + if (!config || !timelineCards ||timelineCards.length == 0) { return ; } @@ -121,7 +175,7 @@ function History() {
{Object.entries(timelineCards) .reverse() - .map(([day, timelineDay]) => { + .map(([day, timelineDay], dayIdx) => { return (
@@ -129,37 +183,58 @@ function History() { strftime_fmt: "%A %b %d", })} - {Object.entries(timelineDay).map(([hour, timelineHour]) => { - if (Object.values(timelineHour).length == 0) { - return <>; - } + {Object.entries(timelineDay).map( + ([hour, timelineHour], hourIdx) => { + if (Object.values(timelineHour).length == 0) { + return
; + } - return ( -
- - {formatUnixTimestampToDateTime(parseInt(hour), { - strftime_fmt: "%I:00", - })} - - -
+ 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: "%I:00", + })} + + +
{Object.entries(timelineHour).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 ( ); } )}
- - -
- ); - })} + {lastRow && } +
+ ); + } + )}
); })} diff --git a/web-new/src/utils/dateUtil.ts b/web-new/src/utils/dateUtil.ts index 8400aa549..053401b0b 100644 --- a/web-new/src/utils/dateUtil.ts +++ b/web-new/src/utils/dateUtil.ts @@ -105,7 +105,7 @@ const getResolvedTimeZone = () => { */ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiConfig): string => { const { timezone, time_format, date_style, time_style, strftime_fmt } = config; - const locale = window.navigator?.language || 'en-us'; + const locale = window.navigator?.language || 'en-US'; if (isNaN(unixTimestamp)) { return 'Invalid time'; } @@ -117,7 +117,7 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiC // use strftime_fmt if defined in config if (strftime_fmt) { const offset = getUTCOffset(date, timezone || resolvedTimeZone); - const strftime_locale = strftime.timezone(offset).localizeByIdentifier(locale); + const strftime_locale = strftime.timezone(offset); return strftime_locale(strftime_fmt, date); } diff --git a/web-new/tailwind.config.js b/web-new/tailwind.config.js index 0377ea1de..e6180eacd 100644 --- a/web-new/tailwind.config.js +++ b/web-new/tailwind.config.js @@ -70,6 +70,10 @@ module.exports = { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", }, + screens: { + "xs": "480px", + "2xl": "1400px", + }, }, }, plugins: [require("tailwindcss-animate")], diff --git a/web-new/vite.config.ts b/web-new/vite.config.ts index a97dbd014..5d5bf8207 100644 --- a/web-new/vite.config.ts +++ b/web-new/vite.config.ts @@ -12,24 +12,24 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:5000', + target: 'http://192.168.50.106:5000', ws: true, }, '/vod': { - target: 'http://localhost:5000' + target: 'http://192.168.50.106:5000' }, '/clips': { - target: 'http://localhost:5000' + target: 'http://192.168.50.106:5000' }, '/exports': { - target: 'http://localhost:5000' + target: 'http://192.168.50.106:5000' }, '/ws': { - target: 'ws://localhost:5000', + target: 'ws://192.168.50.106:5000', ws: true, }, '/live': { - target: 'ws://localhost:5000', + target: 'ws://192.168.50.106:5000', changeOrigin: true, ws: true, },