From 8c6d5031d5b8fc05c060725241e6e14b9bfa3dcf Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:42:55 -0600 Subject: [PATCH] fix stutter by moving intersect observer to media card Instead of unloading the carousel, it now unloads the media card rendering while retaining sizing and metadata, but still saves on resources. --- src/components/media/MediaCard.tsx | 91 ++++++++++++++++++- .../discover/components/LazyTabContent.tsx | 29 ------ .../discover/components/MediaCarousel.tsx | 74 ++++++--------- src/pages/discover/discoverContent.tsx | 13 ++- 4 files changed, 122 insertions(+), 85 deletions(-) delete mode 100644 src/pages/discover/components/LazyTabContent.tsx 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 */}