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.
This commit is contained in:
Pas 2025-12-01 16:57:17 -07:00
parent b464c2195e
commit ebdb931d59
4 changed files with 283 additions and 116 deletions

View file

@ -0,0 +1,49 @@
import { useEffect, useRef, useState } from "react";
interface UseIntersectionObserverOptions {
threshold?: number;
root?: Element | null;
rootMargin?: string;
}
export function useIntersectionObserver<T extends HTMLElement = HTMLDivElement>(
options: UseIntersectionObserverOptions = {},
) {
const { threshold = 0.1, root = null, rootMargin = "0px" } = options;
const [isIntersecting, setIsIntersecting] = useState(false);
const [hasIntersected, setHasIntersected] = useState(false);
const elementRef = useRef<T | null>(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,
};
}

View file

@ -19,7 +19,7 @@ import { useDiscoverStore } from "@/stores/discover";
import { useOverlayStack } from "@/stores/interface/overlayStack"; import { useOverlayStack } from "@/stores/interface/overlayStack";
import { MediaItem } from "@/utils/mediaTypes"; import { MediaItem } from "@/utils/mediaTypes";
import { MediaCarousel } from "./components/MediaCarousel"; import { LazyMediaCarousel } from "./components/LazyMediaCarousel";
export function DiscoverMore() { export function DiscoverMore() {
const [curatedLists, setCuratedLists] = useState<CuratedMovieList[]>([]); const [curatedLists, setCuratedLists] = useState<CuratedMovieList[]>([]);
@ -107,21 +107,23 @@ export function DiscoverMore() {
<WideContainer ultraWide> <WideContainer ultraWide>
{/* Latest Movies */} {/* Latest Movies */}
<div className="relative"> <div className="relative">
<MediaCarousel <LazyMediaCarousel
content={{ type: "latest", fallback: "nowPlaying" }} content={{ type: "latest", fallback: "nowPlaying" }}
isTVShow={false} isTVShow={false}
carouselRefs={carouselRefs} carouselRefs={carouselRefs}
onShowDetails={handleShowDetails} onShowDetails={handleShowDetails}
priority // Load immediately as first carousel
/> />
</div> </div>
{/* Top Rated Movies */} {/* Top Rated Movies */}
<div className="relative"> <div className="relative">
<MediaCarousel <LazyMediaCarousel
content={{ type: "latest4k", fallback: "topRated" }} content={{ type: "latest4k", fallback: "topRated" }}
isTVShow={false} isTVShow={false}
carouselRefs={carouselRefs} carouselRefs={carouselRefs}
onShowDetails={handleShowDetails} onShowDetails={handleShowDetails}
priority // Load immediately as second carousel
/> />
</div> </div>

View file

@ -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<HTMLDivElement>({
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 (
<div ref={ref}>
{shouldRender ? (
<MediaCarousel
content={content}
isTVShow={isTVShow}
carouselRefs={carouselRefs}
onShowDetails={onShowDetails}
moreContent={moreContent}
moreLink={moreLink}
showProviders={showProviders}
showGenres={showGenres}
showRecommendations={showRecommendations}
/>
) : (
// Placeholder with similar height to prevent layout shift
<div className="h-[20rem]" />
)}
</div>
);
}

View file

@ -12,7 +12,7 @@ import { MediaItem } from "@/utils/mediaTypes";
import { DiscoverNavigation } from "./components/DiscoverNavigation"; import { DiscoverNavigation } from "./components/DiscoverNavigation";
import type { FeaturedMedia } from "./components/FeaturedCarousel"; import type { FeaturedMedia } from "./components/FeaturedCarousel";
import { MediaCarousel } from "./components/MediaCarousel"; import { LazyMediaCarousel } from "./components/LazyMediaCarousel";
import { ScrollToTopButton } from "./components/ScrollToTopButton"; import { ScrollToTopButton } from "./components/ScrollToTopButton";
export function DiscoverContent() { export function DiscoverContent() {
@ -47,153 +47,199 @@ export function DiscoverContent() {
// Render Movies content with lazy loading // Render Movies content with lazy loading
const renderMoviesContent = () => { const renderMoviesContent = () => {
return ( const carousels = [];
<>
{/* Movie Recommendations - only show if there are movie progress items */}
{movieProgressItems.length > 0 && (
<MediaCarousel
content={{ type: "recommendations" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
showRecommendations
/>
)}
{/* Latest Releases */} // Movie Recommendations - only show if there are movie progress items
<MediaCarousel if (movieProgressItems.length > 0) {
content={{ type: "latest", fallback: "nowPlaying" }} carousels.push(
<LazyMediaCarousel
key="movie-recommendations"
content={{ type: "recommendations" }}
isTVShow={false} isTVShow={false}
carouselRefs={carouselRefs} carouselRefs={carouselRefs}
onShowDetails={handleShowDetails} onShowDetails={handleShowDetails}
moreContent moreContent
/> showRecommendations
priority={carousels.length < 2} // First 2 carousels load immediately
/>,
);
}
{/* 4K Releases */} // Latest Releases
<MediaCarousel carousels.push(
content={{ type: "latest4k", fallback: "popular" }} <LazyMediaCarousel
isTVShow={false} key="movie-latest"
carouselRefs={carouselRefs} content={{ type: "latest", fallback: "nowPlaying" }}
onShowDetails={handleShowDetails} isTVShow={false}
moreContent carouselRefs={carouselRefs}
/> onShowDetails={handleShowDetails}
moreContent
{/* Top Rated */} priority={carousels.length < 2}
<MediaCarousel />,
content={{ type: "topRated" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Provider Movies */}
<MediaCarousel
content={{ type: "provider" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
showProviders
moreContent
/>
{/* Genre Movies */}
<MediaCarousel
content={{ type: "genre" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
showGenres
moreContent
/>
</>
); );
// 4K Releases
carousels.push(
<LazyMediaCarousel
key="movie-4k"
content={{ type: "latest4k", fallback: "popular" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
priority={carousels.length < 2}
/>,
);
// Top Rated
carousels.push(
<LazyMediaCarousel
key="movie-top-rated"
content={{ type: "topRated" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
priority={carousels.length < 2}
/>,
);
// Provider Movies
carousels.push(
<LazyMediaCarousel
key="movie-providers"
content={{ type: "provider" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
showProviders
moreContent
/>,
);
// Genre Movies
carousels.push(
<LazyMediaCarousel
key="movie-genres"
content={{ type: "genre" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
showGenres
moreContent
/>,
);
return carousels;
}; };
// Render TV Shows content with lazy loading // Render TV Shows content with lazy loading
const renderTVShowsContent = () => { const renderTVShowsContent = () => {
return ( const carousels = [];
<>
{/* TV Show Recommendations - only show if there are TV show progress items */}
{tvProgressItems.length > 0 && (
<MediaCarousel
content={{ type: "recommendations" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
showRecommendations
/>
)}
{/* On Air */} // TV Show Recommendations - only show if there are TV show progress items
<MediaCarousel if (tvProgressItems.length > 0) {
content={{ type: "latesttv", fallback: "onTheAir" }} carousels.push(
<LazyMediaCarousel
key="tv-recommendations"
content={{ type: "recommendations" }}
isTVShow isTVShow
carouselRefs={carouselRefs} carouselRefs={carouselRefs}
onShowDetails={handleShowDetails} onShowDetails={handleShowDetails}
moreContent moreContent
/> showRecommendations
priority={carousels.length < 2} // First 2 carousels load immediately
/>,
);
}
{/* Top Rated */} // On Air
<MediaCarousel carousels.push(
content={{ type: "topRated" }} <LazyMediaCarousel
isTVShow key="tv-on-air"
carouselRefs={carouselRefs} content={{ type: "latesttv", fallback: "onTheAir" }}
onShowDetails={handleShowDetails} isTVShow
moreContent carouselRefs={carouselRefs}
/> onShowDetails={handleShowDetails}
moreContent
{/* Popular */} priority={carousels.length < 2}
<MediaCarousel />,
content={{ type: "popular" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Provider TV Shows */}
<MediaCarousel
content={{ type: "provider" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
showProviders
moreContent
/>
{/* Genre TV Shows */}
<MediaCarousel
content={{ type: "genre" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
showGenres
moreContent
/>
</>
); );
// Top Rated
carousels.push(
<LazyMediaCarousel
key="tv-top-rated"
content={{ type: "topRated" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
priority={carousels.length < 2}
/>,
);
// Popular
carousels.push(
<LazyMediaCarousel
key="tv-popular"
content={{ type: "popular" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
priority={carousels.length < 2}
/>,
);
// Provider TV Shows
carousels.push(
<LazyMediaCarousel
key="tv-providers"
content={{ type: "provider" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
showProviders
moreContent
/>,
);
// Genre TV Shows
carousels.push(
<LazyMediaCarousel
key="tv-genres"
content={{ type: "genre" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
showGenres
moreContent
/>,
);
return carousels;
}; };
// Render Editor Picks content // Render Editor Picks content
const renderEditorPicksContent = () => { const renderEditorPicksContent = () => {
return ( return (
<> <>
<MediaCarousel <LazyMediaCarousel
content={{ type: "editorPicks" }} content={{ type: "editorPicks" }}
isTVShow={false} isTVShow={false}
carouselRefs={carouselRefs} carouselRefs={carouselRefs}
onShowDetails={handleShowDetails} onShowDetails={handleShowDetails}
moreContent moreContent
priority // Editor picks load immediately since they're the main content
/> />
<MediaCarousel <LazyMediaCarousel
content={{ type: "editorPicks" }} content={{ type: "editorPicks" }}
isTVShow isTVShow
carouselRefs={carouselRefs} carouselRefs={carouselRefs}
onShowDetails={handleShowDetails} onShowDetails={handleShowDetails}
moreContent moreContent
priority // Editor picks load immediately since they're the main content
/> />
</> </>
); );