mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
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:
parent
b464c2195e
commit
ebdb931d59
4 changed files with 283 additions and 116 deletions
49
src/hooks/useIntersectionObserver.ts
Normal file
49
src/hooks/useIntersectionObserver.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
70
src/pages/discover/components/LazyMediaCarousel.tsx
Normal file
70
src/pages/discover/components/LazyMediaCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue