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
This commit is contained in:
Nicolas Mowen 2023-12-13 20:15:28 -07:00 committed by Blake Blackshear
parent 1961a0afc1
commit fbe58652d5
7 changed files with 305 additions and 161 deletions

View File

@ -21,12 +21,14 @@ import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
type HistoryCardProps = { type HistoryCardProps = {
timeline: Card; timeline: Card;
allPreviews?: Preview[]; relevantPreview?: Preview;
shouldAutoPlay: boolean;
}; };
export default function HistoryCard({ export default function HistoryCard({
allPreviews, relevantPreview,
timeline, timeline,
shouldAutoPlay,
}: HistoryCardProps) { }: HistoryCardProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -35,11 +37,13 @@ export default function HistoryCard({
} }
return ( return (
<Card className="my-2 mr-2 bg-secondary"> <Card className="my-2 xs:mr-2 bg-secondary w-full xs:w-[48%] sm:w-[284px]">
<PreviewThumbnailPlayer <PreviewThumbnailPlayer
camera={timeline.camera} camera={timeline.camera}
allPreviews={allPreviews || []} relevantPreview={relevantPreview}
startTs={Object.values(timeline.entries)[0].timestamp} startTs={Object.values(timeline.entries)[0].timestamp}
eventId={Object.values(timeline.entries)[0].source_id}
shouldAutoPlay={shouldAutoPlay}
/> />
<div className="p-2"> <div className="p-2">
<div className="text-sm flex"> <div className="text-sm flex">

View File

@ -1,65 +1,122 @@
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import VideoPlayer from "./VideoPlayer"; import VideoPlayer from "./VideoPlayer";
import useSWR from "swr"; import useSWR from "swr";
import { useCallback, useMemo, useRef } from "react"; import { useCallback, useRef } from "react";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import Player from "video.js/dist/types/player"; import Player from "video.js/dist/types/player";
import { AspectRatio } from "../ui/aspect-ratio";
type PreviewPlayerProps = { type PreviewPlayerProps = {
camera: string, camera: string;
allPreviews: Preview[], relevantPreview?: Preview;
startTs: number, startTs: number;
} eventId: string;
shouldAutoPlay: boolean;
};
type Preview = { type Preview = {
camera: string, camera: string;
src: string, src: string;
type: string, type: string;
start: number, start: number;
end: number, end: number;
} };
export default function PreviewThumbnailPlayer({ camera, allPreviews, startTs }: PreviewPlayerProps) { export default function PreviewThumbnailPlayer({
const { data: config } = useSWR('config'); camera,
const playerRef = useRef<Player | null>(null); relevantPreview,
const apiHost = useApiHost(); startTs,
eventId,
shouldAutoPlay,
}: PreviewPlayerProps) {
const { data: config } = useSWR("config");
const playerRef = useRef<Player | null>(null);
const apiHost = useApiHost();
const relevantPreview = useMemo(() => { const onPlayback = useCallback(
return Object.values(allPreviews || []).find( (isHovered: Boolean) => {
(preview) => preview.camera == camera && preview.start < startTs && preview.end > startTs if (!relevantPreview || !playerRef.current) {
); return;
}, [allPreviews, camera, startTs]); }
const onHover = useCallback((isHovered: Boolean) => { if (isHovered) {
if (!relevantPreview || !playerRef.current) { playerRef.current.play();
return; } else {
} playerRef.current.pause();
playerRef.current.currentTime(startTs - relevantPreview.start);
}
},
[relevantPreview, startTs]
);
if (isHovered) { const observer = useRef<IntersectionObserver | null>();
playerRef.current.play(); const inViewRef = useCallback(
} else { (node: HTMLElement | null) => {
playerRef.current.pause(); if (!shouldAutoPlay || observer.current) {
playerRef.current.currentTime(startTs - relevantPreview.start); return;
} }
},
[relevantPreview, startTs]
);
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 (
<img className={getThumbWidth(camera, config)} src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`} /> <AspectRatio
ratio={16 / 9}
className="bg-black flex justify-center items-center"
>
<img
className={`${getPreviewWidth(camera, config)}`}
loading="lazy"
src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`}
/>
</AspectRatio>
); );
} }
return ( return (
<div <AspectRatio
className={getThumbWidth(camera, config)} ratio={16 / 9}
onMouseEnter={() => onHover(true)} className="bg-black flex justify-center items-center"
onMouseLeave={() => onHover(false)}
> >
<img
className="w-[160px]"
loading="lazy"
src={`${apiHost}api/events/${eventId}/thumbnail.jpg`}
/>
</AspectRatio>
);
}
return (
<AspectRatio
ref={shouldAutoPlay ? inViewRef : null}
ratio={16 / 9}
className="bg-black flex justify-center items-center"
onMouseEnter={() => onPlayback(true)}
onMouseLeave={() => onPlayback(false)}
>
<div className={`${getPreviewWidth(camera, config)}`}>
<VideoPlayer <VideoPlayer
options={{ options={{
preload: 'auto', preload: "auto",
autoplay: false, autoplay: false,
controls: false, controls: false,
muted: true, muted: true,
@ -67,7 +124,7 @@ export default function PreviewThumbnailPlayer({ camera, allPreviews, startTs }:
sources: [ sources: [
{ {
src: `${relevantPreview.src}`, src: `${relevantPreview.src}`,
type: 'video/mp4', type: "video/mp4",
}, },
], ],
}} }}
@ -82,18 +139,22 @@ export default function PreviewThumbnailPlayer({ camera, allPreviews, startTs }:
}} }}
/> />
</div> </div>
); </AspectRatio>
);
}
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) { return "w-full";
const detect = config.cameras[camera].detect;
if (detect.width / detect.height > 2) {
return 'w-[320px]';
} }
if (detect.width / detect.height < 1.4) {
return 'w-[200px]';
}
return 'w-[240px]';
}

View File

@ -44,7 +44,7 @@ export default function VideoPlayer({ children, options, seekOptions = {forward:
videoElement.classList.add('small-player'); videoElement.classList.add('small-player');
videoElement.classList.add('video-js'); videoElement.classList.add('video-js');
videoElement.classList.add('vjs-default-skin'); videoElement.classList.add('vjs-default-skin');
videoRef.current.appendChild(videoElement); videoRef.current?.appendChild(videoElement);
const player = playerRef.current = videojs(videoElement, { ...defaultOptions, ...options }, () => { const player = playerRef.current = videojs(videoElement, { ...defaultOptions, ...options }, () => {
onReady && onReady(player); onReady && onReady(player);

View File

@ -1,11 +1,14 @@
import { useMemo, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import ActivityIndicator from "@/components/ui/activity-indicator"; import ActivityIndicator from "@/components/ui/activity-indicator";
import HistoryCard from "@/components/card/HistoryCard"; import HistoryCard from "@/components/card/HistoryCard";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import axios from "axios";
const API_LIMIT = 100;
function History() { function History() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -14,13 +17,35 @@ function History() {
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
[config] [config]
); );
const { data: hourlyTimeline } = useSWR<HourlyTimeline>([ const timelineFetcher = useCallback((key: any) => {
"timeline/hourly", const [path, params] = Array.isArray(key) ? key : [key, undefined];
{ timezone }, 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<HourlyTimeline>(getKey, timelineFetcher);
const { data: allPreviews } = useSWR<Preview[]>( const { data: allPreviews } = useSWR<Preview[]>(
`preview/all/start/${hourlyTimeline?.start || 0}/end/${ `preview/all/start/${(timelinePages ?? [])?.at(0)?.start ?? 0}/end/${
hourlyTimeline?.end || 0 (timelinePages ?? [])?.at(-1)?.end ?? 0
}`, }`,
{ revalidateOnFocus: false } { revalidateOnFocus: false }
); );
@ -30,84 +55,113 @@ function History() {
); );
const timelineCards: CardsData | never[] = useMemo(() => { const timelineCards: CardsData | never[] = useMemo(() => {
if (!hourlyTimeline) { if (!timelinePages) {
return []; return [];
} }
const cards: CardsData = {}; const cards: CardsData = {};
Object.keys(hourlyTimeline["hours"]) timelinePages.forEach((hourlyTimeline) => {
.reverse() Object.keys(hourlyTimeline["hours"])
.forEach((hour) => { .reverse()
const day = new Date(parseInt(hour) * 1000); .forEach((hour) => {
day.setHours(0, 0, 0, 0); const day = new Date(parseInt(hour) * 1000);
const dayKey = (day.getTime() / 1000).toString(); day.setHours(0, 0, 0, 0);
const source_to_types: { [key: string]: string[] } = {}; const dayKey = (day.getTime() / 1000).toString();
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => { const source_to_types: { [key: string]: string[] } = {};
const time = new Date(i.timestamp * 1000); Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
time.setSeconds(0); const time = new Date(i.timestamp * 1000);
time.setMilliseconds(0); time.setSeconds(0);
const key = `${i.source_id}-${time.getMinutes()}`; time.setMilliseconds(0);
if (key in source_to_types) { const key = `${i.source_id}-${time.getMinutes()}`;
source_to_types[key].push(i.class_type); if (key in source_to_types) {
} else { source_to_types[key].push(i.class_type);
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 { } else {
cards[dayKey][hour][key] = { source_to_types[key] = [i.class_type];
camera: i.camera,
time: time.getTime() / 1000,
entries: [i],
};
} }
});
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; 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<IntersectionObserver | null>();
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 <ActivityIndicator />; return <ActivityIndicator />;
} }
@ -121,7 +175,7 @@ function History() {
<div> <div>
{Object.entries(timelineCards) {Object.entries(timelineCards)
.reverse() .reverse()
.map(([day, timelineDay]) => { .map(([day, timelineDay], dayIdx) => {
return ( return (
<div key={day}> <div key={day}>
<Heading as="h3"> <Heading as="h3">
@ -129,37 +183,58 @@ function History() {
strftime_fmt: "%A %b %d", strftime_fmt: "%A %b %d",
})} })}
</Heading> </Heading>
{Object.entries(timelineDay).map(([hour, timelineHour]) => { {Object.entries(timelineDay).map(
if (Object.values(timelineHour).length == 0) { ([hour, timelineHour], hourIdx) => {
return <></>; if (Object.values(timelineHour).length == 0) {
} return <div key={hour}></div>;
}
return ( const lastRow =
<div key={hour}> dayIdx == Object.values(timelineCards).length - 1 &&
<Heading as="h4"> hourIdx == Object.values(timelineDay).length - 1;
{formatUnixTimestampToDateTime(parseInt(hour), { const previewMap: { [key: string]: Preview | undefined } =
strftime_fmt: "%I:00", {};
})}
</Heading> return (
<ScrollArea> <div key={hour} ref={lastRow ? lastTimelineRef : null}>
<div className="flex"> <Heading as="h4">
{formatUnixTimestampToDateTime(parseInt(hour), {
strftime_fmt: "%I:00",
})}
</Heading>
<div className="flex flex-wrap">
{Object.entries(timelineHour).map( {Object.entries(timelineHour).map(
([key, timeline]) => { ([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 ( return (
<HistoryCard <HistoryCard
key={key} key={key}
timeline={timeline} timeline={timeline}
allPreviews={allPreviews} shouldAutoPlay={shouldAutoPlay}
relevantPreview={relevantPreview}
/> />
); );
} }
)} )}
</div> </div>
<ScrollBar className="m-2" orientation="horizontal" /> {lastRow && <ActivityIndicator />}
</ScrollArea> </div>
</div> );
); }
})} )}
</div> </div>
); );
})} })}

View File

@ -105,7 +105,7 @@ const getResolvedTimeZone = () => {
*/ */
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiConfig): string => { export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiConfig): string => {
const { timezone, time_format, date_style, time_style, strftime_fmt } = config; 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)) { if (isNaN(unixTimestamp)) {
return 'Invalid time'; return 'Invalid time';
} }
@ -117,7 +117,7 @@ export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiC
// use strftime_fmt if defined in config // use strftime_fmt if defined in config
if (strftime_fmt) { if (strftime_fmt) {
const offset = getUTCOffset(date, timezone || resolvedTimeZone); 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); return strftime_locale(strftime_fmt, date);
} }

View File

@ -70,6 +70,10 @@ module.exports = {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out",
}, },
screens: {
"xs": "480px",
"2xl": "1400px",
},
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [require("tailwindcss-animate")],

View File

@ -12,24 +12,24 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:5000', target: 'http://192.168.50.106:5000',
ws: true, ws: true,
}, },
'/vod': { '/vod': {
target: 'http://localhost:5000' target: 'http://192.168.50.106:5000'
}, },
'/clips': { '/clips': {
target: 'http://localhost:5000' target: 'http://192.168.50.106:5000'
}, },
'/exports': { '/exports': {
target: 'http://localhost:5000' target: 'http://192.168.50.106:5000'
}, },
'/ws': { '/ws': {
target: 'ws://localhost:5000', target: 'ws://192.168.50.106:5000',
ws: true, ws: true,
}, },
'/live': { '/live': {
target: 'ws://localhost:5000', target: 'ws://192.168.50.106:5000',
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
}, },