From d58e32b1d931151bff7a3d9b2192a51f0c2f18e2 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Tue, 25 Feb 2025 12:52:30 -0700 Subject: [PATCH] Add lazy loading to discover --- src/pages/discover/Discover.tsx | 36 +--- .../discover/components/LazyMediaCarousel.tsx | 78 ++++++++ .../discover/components/LazyTabContent.tsx | 29 +++ src/pages/discover/discoverContent.tsx | 177 ++++++++++++------ .../hooks/useIntersectionObserver.tsx | 43 +++++ src/pages/discover/hooks/useTMDBData.tsx | 70 ++++++- 6 files changed, 332 insertions(+), 101 deletions(-) create mode 100644 src/pages/discover/components/LazyMediaCarousel.tsx create mode 100644 src/pages/discover/components/LazyTabContent.tsx create mode 100644 src/pages/discover/hooks/useIntersectionObserver.tsx diff --git a/src/pages/discover/Discover.tsx b/src/pages/discover/Discover.tsx index 85f97c97..40bc87ac 100644 --- a/src/pages/discover/Discover.tsx +++ b/src/pages/discover/Discover.tsx @@ -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 ( @@ -64,20 +43,7 @@ export function Discover() {

- {/* Conditional rendering: show loading screen or the content */} - {loading ? ( -
- -

- Fetching the latest movies & TV shows... -

-

- Please wait while we load the best recommendations for you. -

-
- ) : ( - - )} +
); } diff --git a/src/pages/discover/components/LazyMediaCarousel.tsx b/src/pages/discover/components/LazyMediaCarousel.tsx new file mode 100644 index 00000000..86dfc799 --- /dev/null +++ b/src/pages/discover/components/LazyMediaCarousel.tsx @@ -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([]); + + // 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 ( +
}> + {isIntersecting ? ( + + ) : ( +
+

+ {categoryName} {mediaType === "tv" ? "Shows" : "Movies"} +

+
+
+ {isLoading ? "Loading..." : ""} +
+
+
+ )} +
+ ); +} diff --git a/src/pages/discover/components/LazyTabContent.tsx b/src/pages/discover/components/LazyTabContent.tsx new file mode 100644 index 00000000..79e59fd4 --- /dev/null +++ b/src/pages/discover/components/LazyTabContent.tsx @@ -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 ( +
+ {hasLoaded ? children : null} +
+ ); +} diff --git a/src/pages/discover/discoverContent.tsx b/src/pages/discover/discoverContent.tsx index 12e85395..01a135d5 100644 --- a/src/pages/discover/discoverContent.tsx +++ b/src/pages/discover/discoverContent.tsx @@ -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("/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("/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 && ( + + )} + + {/* Categories */} + {categories.map((category) => ( + + ))} + + {/* Genres */} + {genres.map((genre) => ( + + ))} + + ); + }; + + // Render TV Shows content with lazy loading + const renderTVShowsContent = () => { + return ( + <> + {/* Provider TV Shows */} + {providerTVShows.length > 0 && ( + + )} + + {/* Categories */} + {tvCategories.map((category) => ( + + ))} + + {/* Genres */} + {tvGenres.map((genre) => ( + + ))} + + ); + }; + return (
{/* Random Movie Button */} @@ -247,63 +345,18 @@ export function DiscoverContent() { />
- {/* Content Section */} -
- {(() => { - 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 && ( - - )} - {/* Categories and Genres */} - {mediaCategories.map((category) => ( - - ))} - {mediaGenres.map((genre) => ( - - ))} - - ); - })()} + {/* Content Section with Lazy Loading Tabs */} +
+ {/* Movies Tab */} + + {renderMoviesContent()} + + + {/* TV Shows Tab */} + + {renderTVShowsContent()} +
diff --git a/src/pages/discover/hooks/useIntersectionObserver.tsx b/src/pages/discover/hooks/useIntersectionObserver.tsx new file mode 100644 index 00000000..5d9850b5 --- /dev/null +++ b/src/pages/discover/hooks/useIntersectionObserver.tsx @@ -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(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 }; +} diff --git a/src/pages/discover/hooks/useTMDBData.tsx b/src/pages/discover/hooks/useTMDBData.tsx index c1112612..c82e84b9 100644 --- a/src/pages/discover/hooks/useTMDBData.tsx +++ b/src/pages/discover/hooks/useTMDBData.tsx @@ -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(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([]); + 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(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 }; }