Drastically improve the loading speed of images in the ui by using a custom loader and abort controller to abort when images leave the view.
This commit is contained in:
@@ -16,6 +16,8 @@ interface DatasetImageCardProps {
|
||||
onDelete?: () => void;
|
||||
onImageClick?: () => void;
|
||||
captionRefreshKey?: number;
|
||||
observerRoot?: Element | null;
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
@@ -27,13 +29,88 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
onDelete = () => {},
|
||||
onImageClick,
|
||||
captionRefreshKey = 0,
|
||||
observerRoot = null,
|
||||
rootMargin = '200px 0px',
|
||||
}) => {
|
||||
const [loaded, setLoaded] = useState<boolean>(false);
|
||||
const [showAudioPlayer, setShowAudioPlayer] = useState(true);
|
||||
const [pollTick, setPollTick] = useState(0);
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isItAVideo = isVideo(imageUrl);
|
||||
const isItAudio = isAudio(imageUrl);
|
||||
const isItImage = !isItAVideo && !isItAudio;
|
||||
|
||||
// Track actual viewport visibility — Virtuoso keeps a buffer of cards mounted
|
||||
// outside the visible region, so we can't rely on mount/unmount alone.
|
||||
useEffect(() => {
|
||||
const el = cardRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === el) {
|
||||
setIsVisible(entry.isIntersecting);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
root: observerRoot ?? null,
|
||||
threshold: 0.01,
|
||||
rootMargin,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [observerRoot, rootMargin]);
|
||||
|
||||
// Drive image loads through fetch + AbortController so scrolling past actually
|
||||
// cancels in-flight requests. Debounced 80ms so fast scroll-throughs never
|
||||
// start a request.
|
||||
useEffect(() => {
|
||||
if (!isItImage) return;
|
||||
if (!isVisible) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
fetch(`/api/img/${encodeURIComponent(imageUrl)}`, { signal: controller.signal })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(objectUrl);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(err => {
|
||||
if (err?.name !== 'AbortError') console.error('Dataset image fetch failed:', err);
|
||||
});
|
||||
}, 80);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
setBlobUrl(null);
|
||||
setLoaded(false);
|
||||
};
|
||||
}, [imageUrl, isItImage, isVisible]);
|
||||
|
||||
const combinedRefreshKey = captionRefreshKey + pollTick;
|
||||
const { caption: fetchedCaption, isLoaded: isCaptionLoaded } = useCaptionBatch(imageUrl, combinedRefreshKey);
|
||||
const { caption: fetchedCaption, isLoaded: isCaptionLoaded } = useCaptionBatch(
|
||||
isVisible ? imageUrl : null,
|
||||
combinedRefreshKey,
|
||||
);
|
||||
|
||||
const [caption, setCaption] = useState<string>('');
|
||||
const [savedCaption, setSavedCaption] = useState<string>('');
|
||||
@@ -90,10 +167,6 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLoad = (): void => {
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -108,12 +181,8 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
|
||||
const isCaptionCurrent = caption.trim() === savedCaption;
|
||||
|
||||
const isItAVideo = isVideo(imageUrl);
|
||||
const isItAudio = isAudio(imageUrl);
|
||||
const isItImage = !isItAVideo && !isItAudio;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${className}`}>
|
||||
<div ref={cardRef} className={`flex flex-col ${className}`}>
|
||||
<div className="relative w-full" style={{ paddingBottom: '100%' }}>
|
||||
<div
|
||||
className={classNames('absolute inset-0 rounded-t-lg shadow-md bg-gray-900', {
|
||||
@@ -148,11 +217,11 @@ const DatasetImageCard: React.FC<DatasetImageCardProps> = ({
|
||||
{isItAudio && showAudioPlayer && (
|
||||
<AudioPlayer src={`/api/img/${encodeURIComponent(imageUrl)}`} title={imageUrl.replace(/^.*[\\/]/, '')} />
|
||||
)}
|
||||
{isItImage && (
|
||||
{isItImage && blobUrl && (
|
||||
<img
|
||||
src={`/api/img/${encodeURIComponent(imageUrl)}`}
|
||||
src={blobUrl}
|
||||
alt={alt}
|
||||
onLoad={handleLoad}
|
||||
decoding="async"
|
||||
onClick={onImageClick}
|
||||
className={classNames('w-full h-full object-contain transition-opacity duration-300', {
|
||||
'opacity-100': loaded,
|
||||
|
||||
@@ -31,6 +31,11 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
|
||||
const isItAudio = isAudio(imageUrl);
|
||||
const isItVideo = isVideo(imageUrl);
|
||||
const isImageType = !isItAudio && !isItVideo;
|
||||
|
||||
// Observe both enter and exit
|
||||
useEffect(() => {
|
||||
@@ -56,9 +61,44 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
|
||||
return () => observer.disconnect();
|
||||
}, [observerRoot, rootMargin]);
|
||||
|
||||
const handleLoad = () => setLoaded(true);
|
||||
// Drive image loads through fetch + AbortController so scrolling past actually
|
||||
// cancels in-flight requests (browsers don't reliably cancel <img> fetches when
|
||||
// the element unmounts). A short debounce skips requests entirely during fast
|
||||
// scrolls where the card is only briefly visible.
|
||||
useEffect(() => {
|
||||
if (!isImageType) return;
|
||||
if (!isVisible) return;
|
||||
|
||||
const isImageType = !isAudio(imageUrl) && !isVideo(imageUrl);
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
fetch(`/api/img/${encodeURIComponent(imageUrl)}`, { signal: controller.signal })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
if (cancelled) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(objectUrl);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(err => {
|
||||
if (err?.name !== 'AbortError') console.error('Sample image fetch failed:', err);
|
||||
});
|
||||
}, 80);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
setBlobUrl(null);
|
||||
setLoaded(false);
|
||||
};
|
||||
}, [isVisible, isImageType, imageUrl]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${className}`}>
|
||||
@@ -69,7 +109,7 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
|
||||
}`}
|
||||
>
|
||||
{isVisible ? (
|
||||
isAudio(imageUrl) ? (
|
||||
isItAudio ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-900">
|
||||
<img
|
||||
src={`/api/audio/art/${encodeURIComponent(imageUrl)}`}
|
||||
@@ -80,31 +120,28 @@ const SampleImageCard: React.FC<SampleImageCardProps> = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isVideo(imageUrl) ? (
|
||||
) : isItVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={`/api/img/${encodeURIComponent(imageUrl)}`}
|
||||
className="w-full h-full object-cover"
|
||||
preload="none"
|
||||
onLoad={handleLoad}
|
||||
playsInline
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
controls={false}
|
||||
/>
|
||||
) : (
|
||||
) : blobUrl ? (
|
||||
<img
|
||||
src={`/api/img/${encodeURIComponent(imageUrl)}`}
|
||||
src={blobUrl}
|
||||
alt={alt}
|
||||
onLoad={handleLoad}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${
|
||||
loaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
)
|
||||
) : null
|
||||
) : null}
|
||||
|
||||
{children && isVisible && <div className="absolute inset-0 flex items-center justify-center">{children}</div>}
|
||||
|
||||
Reference in New Issue
Block a user