mirror of
https://github.com/sussy-code/smov.git
synced 2026-05-09 11:30:32 +00:00
Add lazy loading to discover
This commit is contained in:
parent
7a93410530
commit
d58e32b1d9
6 changed files with 332 additions and 101 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
78
src/pages/discover/components/LazyMediaCarousel.tsx
Normal file
78
src/pages/discover/components/LazyMediaCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/pages/discover/components/LazyTabContent.tsx
Normal file
29
src/pages/discover/components/LazyTabContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
43
src/pages/discover/hooks/useIntersectionObserver.tsx
Normal file
43
src/pages/discover/hooks/useIntersectionObserver.tsx
Normal 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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue