From ebdb931d59f5679ee5717ae4cd9e2e7fc1aad927 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:57:17 -0700 Subject: [PATCH] Add lazy loading for media carousels using Intersection Observer Introduces a reusable useIntersectionObserver hook and a LazyMediaCarousel component to defer rendering of carousels until they are near the viewport. Updates discoverContent and AllMovieLists to use LazyMediaCarousel, improving performance by only loading carousels as needed. Priority carousels (e.g., top of page) are loaded immediately. --- src/hooks/useIntersectionObserver.ts | 49 ++++ src/pages/discover/AllMovieLists.tsx | 8 +- .../discover/components/LazyMediaCarousel.tsx | 70 +++++ src/pages/discover/discoverContent.tsx | 272 ++++++++++-------- 4 files changed, 283 insertions(+), 116 deletions(-) create mode 100644 src/hooks/useIntersectionObserver.ts create mode 100644 src/pages/discover/components/LazyMediaCarousel.tsx diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts new file mode 100644 index 00000000..252d6d0d --- /dev/null +++ b/src/hooks/useIntersectionObserver.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from "react"; + +interface UseIntersectionObserverOptions { + threshold?: number; + root?: Element | null; + rootMargin?: string; +} + +export function useIntersectionObserver( + options: UseIntersectionObserverOptions = {}, +) { + const { threshold = 0.1, root = null, rootMargin = "0px" } = options; + const [isIntersecting, setIsIntersecting] = useState(false); + const [hasIntersected, setHasIntersected] = useState(false); + const elementRef = useRef(null); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + const isElementIntersecting = entry.isIntersecting; + setIsIntersecting(isElementIntersecting); + + if (isElementIntersecting && !hasIntersected) { + setHasIntersected(true); + } + }, + { + threshold, + root, + rootMargin, + }, + ); + + observer.observe(element); + + return () => { + observer.unobserve(element); + }; + }, [threshold, root, rootMargin, hasIntersected]); + + return { + ref: elementRef, + isIntersecting, + hasIntersected, + }; +} diff --git a/src/pages/discover/AllMovieLists.tsx b/src/pages/discover/AllMovieLists.tsx index 8ba5cfa2..316f22c3 100644 --- a/src/pages/discover/AllMovieLists.tsx +++ b/src/pages/discover/AllMovieLists.tsx @@ -19,7 +19,7 @@ import { useDiscoverStore } from "@/stores/discover"; import { useOverlayStack } from "@/stores/interface/overlayStack"; import { MediaItem } from "@/utils/mediaTypes"; -import { MediaCarousel } from "./components/MediaCarousel"; +import { LazyMediaCarousel } from "./components/LazyMediaCarousel"; export function DiscoverMore() { const [curatedLists, setCuratedLists] = useState([]); @@ -107,21 +107,23 @@ export function DiscoverMore() { {/* Latest Movies */}
-
{/* Top Rated Movies */}
-
diff --git a/src/pages/discover/components/LazyMediaCarousel.tsx b/src/pages/discover/components/LazyMediaCarousel.tsx new file mode 100644 index 00000000..8f04d2d5 --- /dev/null +++ b/src/pages/discover/components/LazyMediaCarousel.tsx @@ -0,0 +1,70 @@ +import React from "react"; + +import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; +import { MediaItem } from "@/utils/mediaTypes"; + +import { MediaCarousel } from "./MediaCarousel"; +import { DiscoverContentType } from "../types/discover"; + +interface ContentConfig { + type: DiscoverContentType; + fallback?: DiscoverContentType; +} + +interface LazyMediaCarouselProps { + content: ContentConfig; + isTVShow: boolean; + carouselRefs: React.MutableRefObject<{ + [key: string]: HTMLDivElement | null; + }>; + onShowDetails?: (media: MediaItem) => void; + moreContent?: boolean; + moreLink?: string; + showProviders?: boolean; + showGenres?: boolean; + showRecommendations?: boolean; + priority?: boolean; // For carousels that should load immediately (e.g., first few) +} + +export function LazyMediaCarousel({ + content, + isTVShow, + carouselRefs, + onShowDetails, + moreContent, + moreLink, + showProviders = false, + showGenres = false, + showRecommendations = false, + priority = false, +}: LazyMediaCarouselProps) { + const { ref, hasIntersected } = useIntersectionObserver({ + threshold: 0.1, + rootMargin: "50px", // Start loading when carousel is 50px from viewport + }); + + // Always render if priority is true (for top carousels) + // Otherwise, only render when intersected + const shouldRender = priority || hasIntersected; + + return ( +
+ {shouldRender ? ( + + ) : ( + // Placeholder with similar height to prevent layout shift +
+ )} +
+ ); +} diff --git a/src/pages/discover/discoverContent.tsx b/src/pages/discover/discoverContent.tsx index b060bb9c..1d2c9f99 100644 --- a/src/pages/discover/discoverContent.tsx +++ b/src/pages/discover/discoverContent.tsx @@ -12,7 +12,7 @@ import { MediaItem } from "@/utils/mediaTypes"; import { DiscoverNavigation } from "./components/DiscoverNavigation"; import type { FeaturedMedia } from "./components/FeaturedCarousel"; -import { MediaCarousel } from "./components/MediaCarousel"; +import { LazyMediaCarousel } from "./components/LazyMediaCarousel"; import { ScrollToTopButton } from "./components/ScrollToTopButton"; export function DiscoverContent() { @@ -47,153 +47,199 @@ export function DiscoverContent() { // Render Movies content with lazy loading const renderMoviesContent = () => { - return ( - <> - {/* Movie Recommendations - only show if there are movie progress items */} - {movieProgressItems.length > 0 && ( - - )} + const carousels = []; - {/* Latest Releases */} - 0) { + carousels.push( + + showRecommendations + priority={carousels.length < 2} // First 2 carousels load immediately + />, + ); + } - {/* 4K Releases */} - - - {/* Top Rated */} - - - {/* Provider Movies */} - - - {/* Genre Movies */} - - + // Latest Releases + carousels.push( + , ); + + // 4K Releases + carousels.push( + , + ); + + // Top Rated + carousels.push( + , + ); + + // Provider Movies + carousels.push( + , + ); + + // Genre Movies + carousels.push( + , + ); + + return carousels; }; // Render TV Shows content with lazy loading const renderTVShowsContent = () => { - return ( - <> - {/* TV Show Recommendations - only show if there are TV show progress items */} - {tvProgressItems.length > 0 && ( - - )} + const carousels = []; - {/* On Air */} - 0) { + carousels.push( + + showRecommendations + priority={carousels.length < 2} // First 2 carousels load immediately + />, + ); + } - {/* Top Rated */} - - - {/* Popular */} - - - {/* Provider TV Shows */} - - - {/* Genre TV Shows */} - - + // On Air + carousels.push( + , ); + + // Top Rated + carousels.push( + , + ); + + // Popular + carousels.push( + , + ); + + // Provider TV Shows + carousels.push( + , + ); + + // Genre TV Shows + carousels.push( + , + ); + + return carousels; }; // Render Editor Picks content const renderEditorPicksContent = () => { return ( <> - - );