Add lazy loading to discover

This commit is contained in:
Pas 2025-02-25 12:52:30 -07:00
parent 7a93410530
commit d58e32b1d9
6 changed files with 332 additions and 101 deletions

View file

@ -1,8 +1,6 @@
import React, { useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { Loading } from "@/components/layout/Loading";
import DiscoverContent from "@/pages/discover/discoverContent";
import { SubPageLayout } from "../layouts/SubPageLayout";
@ -11,25 +9,6 @@ import { PageTitle } from "../parts/util/PageTitle";
export function Discover() {
const { t } = useTranslation();
// Stupid method to "simulate" loading so the user understands this takes a white to load.
// TO DO: Lazy load all the media cards 💀
// State to track whether content is loading or loaded
const [loading, setLoading] = useState(true);
// Simulate loading media cards
useEffect(() => {
const simulateLoading = async () => {
// Simulate a loading time with setTimeout or fetch data here
await new Promise((resolve) => {
setTimeout(resolve, 2000);
}); // Simulate 2s loading time
setLoading(false); // After loading, set loading to false
};
simulateLoading();
}, []);
return (
<SubPageLayout>
<Helmet>
@ -64,20 +43,7 @@ export function Discover() {
</p>
</div>
{/* Conditional rendering: show loading screen or the content */}
{loading ? (
<div className="flex flex-col justify-center items-center h-64 space-y-4">
<Loading />
<p className="text-lg font-medium text-gray-400 animate-pulse mt-4">
Fetching the latest movies & TV shows...
</p>
<p className="text-sm text-gray-500">
Please wait while we load the best recommendations for you.
</p>
</div>
) : (
<DiscoverContent />
)}
<DiscoverContent />
</SubPageLayout>
);
}

View file

@ -0,0 +1,78 @@
import { useEffect, useState } from "react";
import { Category, Genre, Media } from "@/pages/discover/common";
import { useIntersectionObserver } from "@/pages/discover/hooks/useIntersectionObserver";
import { useLazyTMDBData } from "@/pages/discover/hooks/useTMDBData";
import { MediaCarousel } from "./MediaCarousel";
interface LazyMediaCarouselProps {
category?: Category | null;
genre?: Genre | null;
mediaType: "movie" | "tv";
movieWidth: string;
isMobile: boolean;
carouselRefs: React.MutableRefObject<{
[key: string]: HTMLDivElement | null;
}>;
}
export function LazyMediaCarousel({
category,
genre,
mediaType,
movieWidth,
isMobile,
carouselRefs,
}: LazyMediaCarouselProps) {
const [medias, setMedias] = useState<Media[]>([]);
// Use intersection observer to detect when this component is visible
const { targetRef, isIntersecting, hasIntersected } = useIntersectionObserver(
{ rootMargin: "200px" }, // Load when within 200px of viewport
true, // Only trigger once
);
// Use the lazy loading hook to fetch data when visible
const { media, isLoading } = useLazyTMDBData(
genre || null,
category || null,
mediaType,
hasIntersected,
);
// Update medias when data is loaded
useEffect(() => {
if (media.length > 0) {
setMedias(media);
}
}, [media]);
const categoryName = category?.name || genre?.name || "";
return (
<div ref={targetRef as React.RefObject<HTMLDivElement>}>
{isIntersecting ? (
<MediaCarousel
medias={medias}
category={categoryName}
isTVShow={mediaType === "tv"}
movieWidth={movieWidth}
isMobile={isMobile}
carouselRefs={carouselRefs}
/>
) : (
<div className="relative overflow-hidden carousel-container">
<h2 className="ml-2 md:ml-8 mt-2 text-2xl cursor-default font-bold text-white md:text-2xl mx-auto pl-5 text-balance">
{categoryName} {mediaType === "tv" ? "Shows" : "Movies"}
</h2>
<div className="flex whitespace-nowrap pt-0 pb-4 overflow-auto scrollbar rounded-xl overflow-y-hidden h-[300px] animate-pulse bg-background-secondary/20">
<div className="w-full text-center flex items-center justify-center">
{isLoading ? "Loading..." : ""}
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,29 @@
import { ReactNode, useEffect, useState } from "react";
interface LazyTabContentProps {
isActive: boolean;
children: ReactNode;
preloadWhenInactive?: boolean;
}
export function LazyTabContent({
isActive,
children,
preloadWhenInactive = false,
}: LazyTabContentProps) {
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => {
// Load content when tab becomes active or if preload is enabled
if (isActive || preloadWhenInactive) {
setHasLoaded(true);
}
}, [isActive, preloadWhenInactive]);
// Only render children if the tab has been loaded
return (
<div style={{ display: isActive ? "block" : "none" }}>
{hasLoaded ? children : null}
</div>
);
}

View file

@ -13,6 +13,8 @@ import { conf } from "@/setup/config";
import "./discover.css";
import { CategoryButtons } from "./components/CategoryButtons";
import { LazyMediaCarousel } from "./components/LazyMediaCarousel";
import { LazyTabContent } from "./components/LazyTabContent";
import { MediaCarousel } from "./components/MediaCarousel";
import { RandomMovieButton } from "./components/RandomMovieButton";
import { ScrollToTopButton } from "./components/ScrollToTopButton";
@ -64,13 +66,21 @@ export function DiscoverContent() {
// Hooks
const navigate = useNavigate();
const { isMobile } = useIsMobile();
const { genreMedia: genreMovies, categoryMedia: categoryMovies } =
useTMDBData(genres, categories, "movie");
const { genreMedia: genreTVShows, categoryMedia: categoryTVShows } =
useTMDBData(tvGenres, tvCategories, "tv");
const { genreMedia: genreMovies } = useTMDBData(genres, categories, "movie");
// const { genreMedia: genreTVShows } = useTMDBData(
// tvGenres,
// tvCategories,
// "tv",
// );
// Only load data for the active tab
const isMoviesTab = selectedCategory === "movies";
const isTVShowsTab = selectedCategory === "tvshows";
// Fetch TV show genres
useEffect(() => {
if (!isTVShowsTab) return;
const fetchTVGenres = async () => {
try {
const data = await get<any>("/genre/tv/list", {
@ -85,10 +95,12 @@ export function DiscoverContent() {
};
fetchTVGenres();
}, []);
}, [isTVShowsTab]);
// Fetch Movie genres
useEffect(() => {
if (!isMoviesTab) return;
const fetchGenres = async () => {
try {
const data = await get<any>("/genre/movie/list", {
@ -104,7 +116,7 @@ export function DiscoverContent() {
};
fetchGenres();
}, []);
}, [isMoviesTab]);
useEffect(() => {
const handleResize = () => {
@ -182,6 +194,92 @@ export function DiscoverContent() {
}
};
// Render Movies content with lazy loading
const renderMoviesContent = () => {
return (
<>
{/* Provider Movies */}
{providerMovies.length > 0 && (
<MediaCarousel
medias={providerMovies}
category={selectedProvider.name}
isTVShow={false}
movieWidth={movieWidth}
isMobile={isMobile}
carouselRefs={carouselRefs}
/>
)}
{/* Categories */}
{categories.map((category) => (
<LazyMediaCarousel
key={category.name}
category={category}
mediaType="movie"
movieWidth={movieWidth}
isMobile={isMobile}
carouselRefs={carouselRefs}
/>
))}
{/* Genres */}
{genres.map((genre) => (
<LazyMediaCarousel
key={genre.id}
genre={genre}
mediaType="movie"
movieWidth={movieWidth}
isMobile={isMobile}
carouselRefs={carouselRefs}
/>
))}
</>
);
};
// Render TV Shows content with lazy loading
const renderTVShowsContent = () => {
return (
<>
{/* Provider TV Shows */}
{providerTVShows.length > 0 && (
<MediaCarousel
medias={providerTVShows}
category={selectedProvider.name}
isTVShow
movieWidth={movieWidth}
isMobile={isMobile}
carouselRefs={carouselRefs}
/>
)}
{/* Categories */}
{tvCategories.map((category) => (
<LazyMediaCarousel
key={category.name}
category={category}
mediaType="tv"
movieWidth={movieWidth}
isMobile={isMobile}
carouselRefs={carouselRefs}
/>
))}
{/* Genres */}
{tvGenres.map((genre) => (
<LazyMediaCarousel
key={genre.id}
genre={genre}
mediaType="tv"
movieWidth={movieWidth}
isMobile={isMobile}
carouselRefs={carouselRefs}
/>
))}
</>
);
};
return (
<div>
{/* Random Movie Button */}
@ -247,63 +345,18 @@ export function DiscoverContent() {
/>
</div>
</div>
{/* Content Section */}
<div className="w-full md:w-[90%] max-w-[2400px] mx-auto">
{(() => {
const isMovieCategory = selectedCategory === "movies";
const providerMedia = isMovieCategory
? providerMovies
: providerTVShows;
const mediaGenres = isMovieCategory ? genres : tvGenres;
const mediaCategories = isMovieCategory ? categories : tvCategories;
return (
<>
{/* Media Carousels */}
{providerMedia.length > 0 && (
<MediaCarousel
medias={providerMedia}
category={selectedProvider.name}
isTVShow={!isMovieCategory}
movieWidth={movieWidth}
isMobile={isMobile}
carouselRefs={carouselRefs}
/>
)}
{/* Categories and Genres */}
{mediaCategories.map((category) => (
<MediaCarousel
key={category.name}
medias={
isMovieCategory
? categoryMovies[category.name] || []
: categoryTVShows[category.name] || []
}
category={category.name}
isTVShow={!isMovieCategory}
movieWidth={movieWidth}
isMobile={isMobile}
carouselRefs={carouselRefs}
/>
))}
{mediaGenres.map((genre) => (
<MediaCarousel
key={genre.id}
medias={
isMovieCategory
? genreMovies[genre.id] || []
: genreTVShows[genre.id] || []
}
category={genre.name}
isTVShow={!isMovieCategory}
movieWidth={movieWidth}
isMobile={isMobile}
carouselRefs={carouselRefs}
/>
))}
</>
);
})()}
{/* Content Section with Lazy Loading Tabs */}
<div className="w-full md:w-[90%] max-w-[2400px] mx-auto">
{/* Movies Tab */}
<LazyTabContent isActive={isMoviesTab}>
{renderMoviesContent()}
</LazyTabContent>
{/* TV Shows Tab */}
<LazyTabContent isActive={isTVShowsTab}>
{renderTVShowsContent()}
</LazyTabContent>
</div>
<ScrollToTopButton />

View file

@ -0,0 +1,43 @@
import { useEffect, useRef, useState } from "react";
interface IntersectionObserverOptions {
root?: Element | null;
rootMargin?: string;
threshold?: number | number[];
}
export function useIntersectionObserver(
options: IntersectionObserverOptions = {},
onceOnly = false,
) {
const [isIntersecting, setIsIntersecting] = useState(false);
const [hasIntersected, setHasIntersected] = useState(false);
const targetRef = useRef<Element | null>(null);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
if (entry.isIntersecting && !hasIntersected) {
setHasIntersected(true);
}
}, options);
const currentTarget = targetRef.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [options, hasIntersected]);
// If onceOnly is true, we only care if it has ever intersected
// Otherwise, we care about the current intersection state
const shouldLoad = onceOnly ? hasIntersected : isIntersecting;
return { targetRef, isIntersecting: shouldLoad, hasIntersected };
}

View file

@ -10,6 +10,7 @@ export function useTMDBData(
genres: Genre[],
categories: Category[],
mediaType: MediaType,
shouldLoad = true,
) {
const [genreMedia, setGenreMedia] = useState<{
[id: number]: Movie[] | TVShow[];
@ -17,13 +18,15 @@ export function useTMDBData(
const [categoryMedia, setCategoryMedia] = useState<{
[categoryName: string]: Movie[] | TVShow[];
}>({});
const [isLoading, setIsLoading] = useState(false);
// Unified fetch function
const fetchMedia = useCallback(
async (endpoint: string, key: string, isGenre: boolean) => {
try {
const media: Movie[] | TVShow[] = [];
for (let page = 1; page <= 6; page += 1) {
// Reduce the number of pages to improve performance
for (let page = 1; page <= 2; page += 1) {
const data = await get<any>(endpoint, {
api_key: conf().TMDB_READ_API_KEY,
language: "en-US",
@ -53,7 +56,10 @@ export function useTMDBData(
// Fetch media for each genre
useEffect(() => {
if (!shouldLoad || genres.length === 0) return;
const fetchMediaForGenres = async () => {
setIsLoading(true);
const genrePromises = genres.map(async (genre) => {
const media = await fetchMedia(
`/discover/${mediaType}`,
@ -63,23 +69,79 @@ export function useTMDBData(
setGenreMedia((prev) => ({ ...prev, [genre.id]: media }));
});
await Promise.all(genrePromises);
setIsLoading(false);
};
fetchMediaForGenres();
}, [genres, mediaType, fetchMedia]);
}, [genres, mediaType, fetchMedia, shouldLoad]);
// Fetch media for each category
useEffect(() => {
if (!shouldLoad || categories.length === 0) return;
const fetchMediaForCategories = async () => {
setIsLoading(true);
const categoryPromises = categories.map(async (category) => {
const media = await fetchMedia(category.endpoint, category.name, false);
setCategoryMedia((prev) => ({ ...prev, [category.name]: media }));
});
await Promise.all(categoryPromises);
setIsLoading(false);
};
fetchMediaForCategories();
}, [categories, mediaType, fetchMedia]);
}, [categories, mediaType, fetchMedia, shouldLoad]);
return { genreMedia, categoryMedia };
return { genreMedia, categoryMedia, isLoading };
}
// Create a hook for lazy loading a specific genre or category
export function useLazyTMDBData(
genre: Genre | null,
category: Category | null,
mediaType: MediaType,
shouldLoad = false,
) {
const [media, setMedia] = useState<Movie[] | TVShow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const fetchMedia = useCallback(
async (endpoint: string, key: string, isGenre: boolean) => {
try {
setIsLoading(true);
const mediaItems: Movie[] | TVShow[] = [];
// Only fetch one page for better performance
const data = await get<any>(endpoint, {
api_key: conf().TMDB_READ_API_KEY,
language: "en-US",
page: "1",
...(isGenre ? { with_genres: key } : {}),
});
mediaItems.push(...data.results);
setMedia(mediaItems);
setIsLoading(false);
return mediaItems;
} catch (error) {
console.error(
`Error fetching ${mediaType} for ${isGenre ? "genre" : "category"}:`,
error,
);
setIsLoading(false);
return [];
}
},
[mediaType],
);
useEffect(() => {
if (!shouldLoad) return;
if (genre) {
fetchMedia(`/discover/${mediaType}`, genre.id.toString(), true);
} else if (category) {
fetchMedia(category.endpoint, category.name, false);
}
}, [genre, category, mediaType, fetchMedia, shouldLoad]);
return { media, isLoading };
}