import React, { useEffect, useState, ReactNode, KeyboardEvent, useRef } from 'react'; import { FaTrashAlt, FaPlay } from 'react-icons/fa'; import { openConfirm } from './ConfirmModal'; import classNames from 'classnames'; import { apiClient } from '@/utils/api'; import AudioPlayer from './AudioPlayer'; import { isVideo, isAudio } from '@/utils/basic'; import useCaptionBatch, { setCachedCaption } from '@/hooks/useCaptionBatch'; interface DatasetImageCardProps { imageUrl: string; alt: string; isAutoCaptioning: boolean; children?: ReactNode; className?: string; onDelete?: () => void; onImageClick?: () => void; captionRefreshKey?: number; observerRoot?: Element | null; rootMargin?: string; captionExt?: string; } const DatasetImageCard: React.FC = ({ imageUrl, alt, isAutoCaptioning, children, className = '', onDelete = () => {}, onImageClick, captionRefreshKey = 0, observerRoot = null, rootMargin = '200px 0px', captionExt = 'txt', }) => { const [loaded, setLoaded] = useState(false); const [showAudioPlayer, setShowAudioPlayer] = useState(true); const [pollTick, setPollTick] = useState(0); const [blobUrl, setBlobUrl] = useState(null); const [streamVideo, setStreamVideo] = useState(false); const [isVisible, setIsVisible] = useState(false); const cardRef = useRef(null); const isItAVideo = isVideo(imageUrl); const isItAudio = isAudio(imageUrl); // 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. Both images and videos pull the 300x300 thumb (the server // generates it on a miss); ?thumb=1 falls through to the real file only when // a thumb can't be made, so a video/* response means "no thumb available" — // abort before downloading the body and stream a