Implement lazy loading for video previews

This commit is contained in:
Nicolas Mowen 2025-05-08 11:51:36 -06:00
parent f639fa82ed
commit cac02b5b56
2 changed files with 104 additions and 38 deletions

View File

@ -23,6 +23,7 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type PreviewPlayerProps = { type PreviewPlayerProps = {
previewRef?: (ref: HTMLDivElement | null) => void;
className?: string; className?: string;
camera: string; camera: string;
timeRange: TimeRange; timeRange: TimeRange;
@ -30,16 +31,19 @@ type PreviewPlayerProps = {
startTime?: number; startTime?: number;
isScrubbing: boolean; isScrubbing: boolean;
forceAspect?: number; forceAspect?: number;
isVisible?: boolean;
onControllerReady: (controller: PreviewController) => void; onControllerReady: (controller: PreviewController) => void;
onClick?: () => void; onClick?: () => void;
}; };
export default function PreviewPlayer({ export default function PreviewPlayer({
previewRef,
className, className,
camera, camera,
timeRange, timeRange,
cameraPreviews, cameraPreviews,
startTime, startTime,
isScrubbing, isScrubbing,
isVisible = true,
onControllerReady, onControllerReady,
onClick, onClick,
}: PreviewPlayerProps) { }: PreviewPlayerProps) {
@ -54,6 +58,7 @@ export default function PreviewPlayer({
if (currentPreview) { if (currentPreview) {
return ( return (
<PreviewVideoPlayer <PreviewVideoPlayer
visibilityRef={previewRef}
className={className} className={className}
camera={camera} camera={camera}
timeRange={timeRange} timeRange={timeRange}
@ -61,6 +66,7 @@ export default function PreviewPlayer({
initialPreview={currentPreview} initialPreview={currentPreview}
startTime={startTime} startTime={startTime}
isScrubbing={isScrubbing} isScrubbing={isScrubbing}
isVisible={isVisible}
currentHourFrame={currentHourFrame} currentHourFrame={currentHourFrame}
onControllerReady={onControllerReady} onControllerReady={onControllerReady}
onClick={onClick} onClick={onClick}
@ -110,6 +116,7 @@ export abstract class PreviewController {
} }
type PreviewVideoPlayerProps = { type PreviewVideoPlayerProps = {
visibilityRef?: (ref: HTMLDivElement | null) => void;
className?: string; className?: string;
camera: string; camera: string;
timeRange: TimeRange; timeRange: TimeRange;
@ -117,12 +124,14 @@ type PreviewVideoPlayerProps = {
initialPreview?: Preview; initialPreview?: Preview;
startTime?: number; startTime?: number;
isScrubbing: boolean; isScrubbing: boolean;
isVisible: boolean;
currentHourFrame?: string; currentHourFrame?: string;
onControllerReady: (controller: PreviewVideoController) => void; onControllerReady: (controller: PreviewVideoController) => void;
onClick?: () => void; onClick?: () => void;
setCurrentHourFrame: (src: string | undefined) => void; setCurrentHourFrame: (src: string | undefined) => void;
}; };
function PreviewVideoPlayer({ function PreviewVideoPlayer({
visibilityRef,
className, className,
camera, camera,
timeRange, timeRange,
@ -130,6 +139,7 @@ function PreviewVideoPlayer({
initialPreview, initialPreview,
startTime, startTime,
isScrubbing, isScrubbing,
isVisible,
currentHourFrame, currentHourFrame,
onControllerReady, onControllerReady,
onClick, onClick,
@ -267,11 +277,13 @@ function PreviewVideoPlayer({
return ( return (
<div <div
ref={visibilityRef}
className={cn( className={cn(
"relative flex w-full justify-center overflow-hidden rounded-lg bg-black md:rounded-2xl", "relative flex w-full justify-center overflow-hidden rounded-lg bg-black md:rounded-2xl",
onClick && "cursor-pointer", onClick && "cursor-pointer",
className, className,
)} )}
data-camera={camera}
onClick={onClick} onClick={onClick}
> >
<img <img
@ -286,45 +298,48 @@ function PreviewVideoPlayer({
previewRef.current?.load(); previewRef.current?.load();
}} }}
/> />
<video {isVisible && (
ref={previewRef} <video
className={`absolute size-full ${currentHourFrame ? "invisible" : "visible"}`} ref={previewRef}
preload="auto" className={`absolute size-full ${currentHourFrame ? "invisible" : "visible"}`}
autoPlay preload="auto"
playsInline autoPlay
muted playsInline
disableRemotePlayback muted
onSeeked={onPreviewSeeked} disableRemotePlayback
onLoadedData={() => { onSeeked={onPreviewSeeked}
if (firstLoad) { onLoadedData={() => {
setFirstLoad(false); if (firstLoad) {
} setFirstLoad(false);
if (controller) {
controller.previewReady();
} else {
previewRef.current?.pause();
}
if (previewRef.current) {
setVideoSize([
previewRef.current.videoWidth,
previewRef.current.videoHeight,
]);
if (startTime && currentPreview) {
previewRef.current.currentTime = startTime - currentPreview.start;
} }
}
}} if (controller) {
> controller.previewReady();
{currentPreview != undefined && ( } else {
<source previewRef.current?.pause();
src={`${baseUrl}${currentPreview.src.substring(1)}`} }
type={currentPreview.type}
/> if (previewRef.current) {
)} setVideoSize([
</video> previewRef.current.videoWidth,
previewRef.current.videoHeight,
]);
if (startTime && currentPreview) {
previewRef.current.currentTime =
startTime - currentPreview.start;
}
}
}}
>
{currentPreview != undefined && (
<source
src={`${baseUrl}${currentPreview.src.substring(1)}`}
type={currentPreview.type}
/>
)}
</video>
)}
{cameraPreviews && !currentPreview && ( {cameraPreviews && !currentPreview && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background_alt text-primary dark:bg-black md:rounded-2xl"> <div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background_alt text-primary dark:bg-black md:rounded-2xl">
{t("noPreviewFoundFor", { camera: camera.replaceAll("_", " ") })} {t("noPreviewFoundFor", { camera: camera.replaceAll("_", " ") })}

View File

@ -385,6 +385,55 @@ export function RecordingView({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewRowRef.current?.scrollWidth, previewRowRef.current?.scrollHeight]); }, [previewRowRef.current?.scrollWidth, previewRowRef.current?.scrollHeight]);
// visibility listener for lazy loading
const [visiblePreviews, setVisiblePreviews] = useState<string[]>([]);
const visiblePreviewObserver = useRef<IntersectionObserver | null>(null);
useEffect(() => {
const visibleCameras = new Set<string>();
visiblePreviewObserver.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const camera = (entry.target as HTMLElement).dataset.camera;
if (!camera) {
return;
}
if (entry.isIntersecting) {
visibleCameras.add(camera);
} else {
visibleCameras.delete(camera);
}
setVisiblePreviews([...visibleCameras]);
});
},
{ threshold: 0.1 },
);
return () => {
visiblePreviewObserver.current?.disconnect();
};
}, []);
const previewRef = useCallback(
(node: HTMLElement | null) => {
if (!visiblePreviewObserver.current) {
return;
}
try {
if (node) visiblePreviewObserver.current.observe(node);
} catch (e) {
// no op
}
},
// we need to listen on the value of the ref
// eslint-disable-next-line react-hooks/exhaustive-deps
[visiblePreviewObserver.current],
);
return ( return (
<div ref={contentRef} className="flex size-full flex-col pt-2"> <div ref={contentRef} className="flex size-full flex-col pt-2">
<Toaster closeButton={true} /> <Toaster closeButton={true} />
@ -631,12 +680,14 @@ export function RecordingView({
}} }}
> >
<PreviewPlayer <PreviewPlayer
previewRef={previewRef}
className="size-full" className="size-full"
camera={cam} camera={cam}
timeRange={currentTimeRange} timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []} cameraPreviews={allPreviews ?? []}
startTime={startTime} startTime={startTime}
isScrubbing={scrubbing} isScrubbing={scrubbing}
isVisible={visiblePreviews.includes(cam)}
onControllerReady={(controller) => { onControllerReady={(controller) => {
previewRefs.current[cam] = controller; previewRefs.current[cam] = controller;
controller.scrubToTimestamp(startTime); controller.scrubToTimestamp(startTime);