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 { MediaItem } from "@/utils/mediaTypes";
import { MediaCarousel } from "./components/MediaCarousel";
import { LazyMediaCarousel } from "./components/LazyMediaCarousel";
export function DiscoverMore() {
const [curatedLists, setCuratedLists] = useState<CuratedMovieList[]>([]);
@ -107,21 +107,23 @@ export function DiscoverMore() {
<WideContainer ultraWide>
{/* Latest Movies */}
<div className="relative">
<MediaCarousel
<LazyMediaCarousel
content={{ type: "latest", fallback: "nowPlaying" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
priority // Load immediately as first carousel
/>
</div>
{/* Top Rated Movies */}
<div className="relative">
<MediaCarousel
<LazyMediaCarousel
content={{ type: "latest4k", fallback: "topRated" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
priority // Load immediately as second carousel
/>
</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 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 && (
<MediaCarousel
content={{ type: "recommendations" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
showRecommendations
/>
)}
const carousels = [];
{/* Latest Releases */}
<MediaCarousel
content={{ type: "latest", fallback: "nowPlaying" }}
// Movie Recommendations - only show if there are movie progress items
if (movieProgressItems.length > 0) {
carousels.push(
<LazyMediaCarousel
key="movie-recommendations"
content={{ type: "recommendations" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
showRecommendations
priority={carousels.length < 2} // First 2 carousels load immediately
/>,
);
}
{/* 4K Releases */}
<MediaCarousel
content={{ type: "latest4k", fallback: "popular" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Top Rated */}
<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
/>
</>
// Latest Releases
carousels.push(
<LazyMediaCarousel
key="movie-latest"
content={{ type: "latest", fallback: "nowPlaying" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
priority={carousels.length < 2}
/>,
);
// 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
const renderTVShowsContent = () => {
return (
<>
{/* 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
/>
)}
const carousels = [];
{/* On Air */}
<MediaCarousel
content={{ type: "latesttv", fallback: "onTheAir" }}
// TV Show Recommendations - only show if there are TV show progress items
if (tvProgressItems.length > 0) {
carousels.push(
<LazyMediaCarousel
key="tv-recommendations"
content={{ type: "recommendations" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
showRecommendations
priority={carousels.length < 2} // First 2 carousels load immediately
/>,
);
}
{/* Top Rated */}
<MediaCarousel
content={{ type: "topRated" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
/>
{/* Popular */}
<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
/>
</>
// On Air
carousels.push(
<LazyMediaCarousel
key="tv-on-air"
content={{ type: "latesttv", fallback: "onTheAir" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
priority={carousels.length < 2}
/>,
);
// 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
const renderEditorPicksContent = () => {
return (
<>
<MediaCarousel
<LazyMediaCarousel
content={{ type: "editorPicks" }}
isTVShow={false}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
priority // Editor picks load immediately since they're the main content
/>
<MediaCarousel
<LazyMediaCarousel
content={{ type: "editorPicks" }}
isTVShow
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
moreContent
priority // Editor picks load immediately since they're the main content
/>
</>
);