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

View File

@ -385,6 +385,55 @@ export function RecordingView({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 (
<div ref={contentRef} className="flex size-full flex-col pt-2">
<Toaster closeButton={true} />
@ -631,12 +680,14 @@ export function RecordingView({
}}
>
<PreviewPlayer
previewRef={previewRef}
className="size-full"
camera={cam}
timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []}
startTime={startTime}
isScrubbing={scrubbing}
isVisible={visiblePreviews.includes(cam)}
onControllerReady={(controller) => {
previewRefs.current[cam] = controller;
controller.scrubToTimestamp(startTime);