diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 778e4f78..94cba547 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -1,7 +1,7 @@ // I'm sorry this is so confusing 😭 import classNames from "classnames"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; @@ -18,6 +18,69 @@ import { IconPatch } from "../buttons/IconPatch"; import { Icon, Icons } from "../Icon"; import { DetailsModal } from "../overlays/detailsModal"; +// Intersection Observer Hook +function useIntersectionObserver(options: IntersectionObserverInit = {}) { + 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) { + setHasIntersected(true); + } + }, + { + ...options, + rootMargin: options.rootMargin || "300px 0px", + }, + ); + + const currentTarget = targetRef.current; + if (currentTarget) { + observer.observe(currentTarget); + } + + return () => { + if (currentTarget) { + observer.unobserve(currentTarget); + } + }; + }, [options]); + + return { targetRef, isIntersecting, hasIntersected }; +} + +// Skeleton Component +function MediaCardSkeleton() { + return ( +
+
+
+ {/* Poster skeleton - matches MediaCard poster dimensions exactly */} +
+ + {/* Title skeleton - matches MediaCard title dimensions */} +
+
+
+
+
+ + {/* Dot list skeleton - matches MediaCard dot list */} +
+
+
+
+
+
+
+
+ ); +} + export interface MediaCardProps { media: MediaItem; linkable?: boolean; @@ -31,6 +94,7 @@ export interface MediaCardProps { closable?: boolean; onClose?: () => void; onShowDetails?: (media: MediaItem) => void; + forceSkeleton?: boolean; } function checkReleased(media: MediaItem): boolean { @@ -55,6 +119,7 @@ function MediaCardContent({ closable, onClose, onShowDetails, + forceSkeleton, }: MediaCardProps) { const { t } = useTranslation(); const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`; @@ -71,6 +136,22 @@ function MediaCardContent({ (state) => state.enableLowPerformanceMode, ); + // Intersection observer for lazy loading + const { targetRef } = useIntersectionObserver({ + rootMargin: "300px", + }); + + // Show skeleton if forced or if media hasn't loaded yet (empty title/poster) + const shouldShowSkeleton = forceSkeleton || (!media.title && !media.poster); + + if (shouldShowSkeleton) { + return ( +
}> + +
+ ); + } + if (isReleased() && media.year) { dotListContent.push(media.year.toFixed()); } @@ -218,7 +299,7 @@ function MediaCardContent({ } export function MediaCard(props: MediaCardProps) { - const { media, onShowDetails } = props; + const { media, onShowDetails, forceSkeleton } = props; const [detailsData, setDetailsData] = useState<{ id: number; type: "movie" | "show"; @@ -275,7 +356,11 @@ export function MediaCard(props: MediaCardProps) { const content = ( <> - + {detailsData && } ); diff --git a/src/pages/discover/components/LazyTabContent.tsx b/src/pages/discover/components/LazyTabContent.tsx deleted file mode 100644 index 79e59fd4..00000000 --- a/src/pages/discover/components/LazyTabContent.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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/components/MediaCarousel.tsx b/src/pages/discover/components/MediaCarousel.tsx index ddc895da..889b4856 100644 --- a/src/pages/discover/components/MediaCarousel.tsx +++ b/src/pages/discover/components/MediaCarousel.tsx @@ -23,64 +23,24 @@ import { MediaItem } from "@/utils/mediaTypes"; import { CarouselNavButtons } from "./CarouselNavButtons"; interface ContentConfig { - /** Primary content type to fetch */ type: DiscoverContentType; - /** Fallback content type if primary fails */ fallback?: DiscoverContentType; } interface MediaCarouselProps { - /** Content configuration for the carousel */ content: ContentConfig; - /** Whether this is a TV show carousel */ isTVShow: boolean; - /** Refs for carousel navigation */ carouselRefs: React.MutableRefObject<{ [key: string]: HTMLDivElement | null; }>; - /** Callback when media details should be shown */ onShowDetails?: (media: MediaItem) => void; - /** Whether to show more content button/link */ moreContent?: boolean; - /** Custom more content link */ moreLink?: string; - /** Whether to show provider selection */ showProviders?: boolean; - /** Whether to show genre selection */ showGenres?: boolean; - /** Whether to show recommendations */ showRecommendations?: boolean; } -function MediaCardSkeleton() { - return ( -
-
-
-
- {/* Poster skeleton - matches MediaCard poster dimensions exactly */} -
- - {/* Title skeleton - matches MediaCard title dimensions */} -
-
-
-
-
- - {/* Dot list skeleton - matches MediaCard dot list */} -
-
-
-
-
-
-
-
-
- ); -} - function MoreCard({ link }: { link: string }) { const { t } = useTranslation(); @@ -364,10 +324,21 @@ export function MediaCarousel({
{Array(10) .fill(null) - .map(() => ( - ( +
+ className="relative mt-4 group cursor-default user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto" + > + +
))}
@@ -586,10 +557,21 @@ export function MediaCarousel({ )) : Array(10) .fill(null) - .map((_, _i) => ( - ( +
+ className="relative mt-4 group cursor-default user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto" + > + +
))} {moreContent && generatedMoreLink && ( diff --git a/src/pages/discover/discoverContent.tsx b/src/pages/discover/discoverContent.tsx index a2ae09ea..fee88691 100644 --- a/src/pages/discover/discoverContent.tsx +++ b/src/pages/discover/discoverContent.tsx @@ -13,7 +13,6 @@ import { MediaItem } from "@/utils/mediaTypes"; import { DiscoverNavigation } from "./components/DiscoverNavigation"; import type { FeaturedMedia } from "./components/FeaturedCarousel"; -import { LazyTabContent } from "./components/LazyTabContent"; import { MediaCarousel } from "./components/MediaCarousel"; import { ScrollToTopButton } from "./components/ScrollToTopButton"; @@ -212,19 +211,19 @@ export function DiscoverContent() { {/* Movies Tab */} - +
{renderMoviesContent()} - +
{/* TV Shows Tab */} - +
{renderTVShowsContent()} - +
{/* Editor Picks Tab */} - +
{renderEditorPicksContent()} - +
{/* View All Button */}