feat: Add lazy loading attribute to images

This commit is contained in:
Peifan Li
2025-12-26 17:45:57 -05:00
parent e03db8f8d6
commit 9a955fa25f
6 changed files with 58 additions and 18 deletions

View File

@@ -125,6 +125,7 @@ const CollectionThumbnail: React.FC<{ video: Video; index: number }> = ({ video,
component="img"
image={src}
alt={video.title}
loading="lazy"
sx={{
width: '100%',
height: '100%',

View File

@@ -55,6 +55,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
const [isImageLoaded, setIsImageLoaded] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// New state for player menu
const [playerMenuAnchor, setPlayerMenuAnchor] = useState<null | HTMLElement>(null);
@@ -64,32 +65,50 @@ const VideoCard: React.FC<VideoCardProps> = ({
const { showSnackbar } = useSnackbar();
const { updateVideo, incrementView } = useVideo();
const handleMouseEnter = () => {
if (!isMobile && video.videoPath) {
setIsHovered(true);
// Add delay before loading video to prevent loading on quick hovers
// This reduces memory usage when quickly moving mouse over multiple cards
hoverTimeoutRef.current = setTimeout(() => {
setIsHovered(true);
}, 300); // 300ms delay
}
};
const handleMouseLeave = () => {
// Clear hover timeout if mouse leaves before delay
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setIsHovered(false);
setIsVideoPlaying(false);
// Cleanup video element when mouse leaves
// Aggressively cleanup video element when mouse leaves
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.src = '';
videoRef.current.load();
// Force garbage collection hint
videoRef.current.removeAttribute('src');
}
};
// Cleanup video element on unmount
useEffect(() => {
return () => {
// Clear any pending hover timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
// Aggressively cleanup video element
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.src = '';
videoRef.current.load();
videoRef.current.removeAttribute('src');
}
};
}, []);
@@ -443,6 +462,7 @@ const VideoCard: React.FC<VideoCardProps> = ({
component="img"
image={thumbnailSrc || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
alt={`${video.title} thumbnail`}
loading="lazy"
onLoad={() => setIsImageLoaded(true)}
sx={{
position: 'absolute',

View File

@@ -60,6 +60,7 @@ const SidebarThumbnail: React.FC<{ video: Video }> = ({ video }) => {
)}
<CardMedia
component="img"
loading="lazy"
sx={{
width: '100%',
height: 94,

View File

@@ -11,8 +11,6 @@ import VideoMetadata from './VideoInfo/VideoMetadata';
import VideoRating from './VideoInfo/VideoRating';
import VideoTags from './VideoInfo/VideoTags';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL;
interface VideoInfoProps {
video: Video;
onTitleSave: (newTitle: string) => Promise<void>;
@@ -50,7 +48,7 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
onUnsubscribe,
onToggleVisibility
}) => {
const { videoRef, videoResolution } = useVideoResolution(video);
const { videoRef, videoResolution, needsDetection } = useVideoResolution(video);
const videoUrl = useCloudStorageUrl(video.videoPath, 'video');
// Cleanup video element on unmount
@@ -67,8 +65,8 @@ const VideoInfo: React.FC<VideoInfoProps> = ({
return (
<Box sx={{ mt: 2 }}>
{/* Hidden video element to get resolution */}
{(videoUrl || video.sourceUrl) && (
{/* Hidden video element to get resolution - only create if needed */}
{needsDetection && (videoUrl || video.sourceUrl) && (
<video
ref={videoRef}
src={videoUrl || video.sourceUrl}

View File

@@ -7,6 +7,7 @@ import { useSnackbar } from './SnackbarContext';
import { useVisitorMode } from './VisitorModeContext';
const API_URL = import.meta.env.VITE_API_URL;
const MAX_SEARCH_RESULTS = 200; // Maximum number of search results to keep in memory
interface VideoContextType {
videos: Video[];
@@ -69,7 +70,7 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
retry: 3, // Reduced from 10 to 3
retryDelay: 1000,
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Garbage collect after 30 minutes (videos are important data)
gcTime: 5 * 60 * 1000, // Garbage collect after 5 minutes (reduced from 30 to save memory)
});
// Filter invisible videos when in visitor mode
@@ -90,7 +91,7 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
retry: 3, // Reduced from 10 to 3
retryDelay: 1000,
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
gcTime: 15 * 60 * 1000, // Garbage collect after 15 minutes
gcTime: 5 * 60 * 1000, // Garbage collect after 5 minutes (reduced from 15 to save memory)
});
const availableTags = settingsData?.tags || [];
@@ -240,7 +241,9 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
});
if (!signal.aborted) {
setSearchResults(response.data.results);
// Limit search results to prevent memory issues
const results = response.data.results || [];
setSearchResults(results.slice(0, MAX_SEARCH_RESULTS));
}
} catch (youtubeErr: any) {
if (youtubeErr.name !== 'CanceledError' && youtubeErr.name !== 'AbortError') {
@@ -278,6 +281,11 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
// Use ref check first to prevent race conditions (immediate, synchronous check)
if (!searchTerm || loadMoreInProgress.current || loadingMore || !showYoutubeSearch) return;
// Don't load more if we've reached the maximum
if (searchResults.length >= MAX_SEARCH_RESULTS) {
return;
}
try {
// Set both state and ref to prevent concurrent requests
loadMoreInProgress.current = true;
@@ -301,8 +309,9 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const existingIds = new Set(prev.map(result => result.id));
// Filter out duplicates by ID
const newResults = response.data.results.filter((result: any) => !existingIds.has(result.id));
// Only append new, non-duplicate results
return [...prev, ...newResults];
// Only append new, non-duplicate results, up to MAX_SEARCH_RESULTS
const combined = [...prev, ...newResults];
return combined.slice(0, MAX_SEARCH_RESULTS);
});
}
} catch (error) {

View File

@@ -54,7 +54,19 @@ export const useVideoResolution = (video: Video) => {
);
const videoUrl = useCloudStorageUrl(video.videoPath, "video");
// First check if resolution is already available in video object
const resolutionFromObject = formatResolution(video);
// Only create video element if resolution is not available in video object
const needsDetection = !resolutionFromObject;
useEffect(() => {
// Skip video element creation if resolution is already available
if (!needsDetection) {
setDetectedResolution(null);
return;
}
const videoElement = videoRef.current;
const videoSrc = videoUrl || video.sourceUrl;
if (!videoElement || !videoSrc) {
@@ -105,11 +117,10 @@ export const useVideoResolution = (video: Video) => {
videoElement.src = "";
videoElement.load();
};
}, [videoUrl, video.sourceUrl, video.id]);
}, [needsDetection, videoUrl, video.sourceUrl, video.id]);
// Try to get resolution from video object first, fallback to detected resolution
const resolutionFromObject = formatResolution(video);
// Use resolution from object if available, otherwise use detected resolution
const videoResolution = resolutionFromObject || detectedResolution;
return { videoRef, videoResolution };
return { videoRef, videoResolution, needsDetection };
};