Refactor MediaCard for internal lazy loading and fix intersection logic

Moved intersection observer logic for lazy loading images from MediaCarousel into MediaCard, allowing each card to handle its own image loading. Simplified MediaCarousel by removing its intersection observer and related loading state, improving component separation and maintainability.
This commit is contained in:
Pas 2025-12-01 16:15:45 -07:00
parent 5e1bd09af5
commit be6aec2c86
2 changed files with 152 additions and 200 deletions

View file

@ -17,23 +17,19 @@ import { MediaBookmarkButton } from "./MediaBookmark";
import { IconPatch } from "../buttons/IconPatch"; import { IconPatch } from "../buttons/IconPatch";
import { Icon, Icons } from "../Icon"; import { Icon, Icons } from "../Icon";
// Intersection Observer Hook // Simple Intersection Observer Hook
function useIntersectionObserver(options: IntersectionObserverInit = {}) { function useIntersectionObserver(options: IntersectionObserverInit = {}) {
const [isIntersecting, setIsIntersecting] = useState(false); const [isIntersecting, setIsIntersecting] = useState(false);
const [hasIntersected, setHasIntersected] = useState(false);
const targetRef = useRef<Element | null>(null); const targetRef = useRef<Element | null>(null);
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
setIsIntersecting(entry.isIntersecting); setIsIntersecting(entry.isIntersecting);
if (entry.isIntersecting) {
setHasIntersected(true);
}
}, },
{ {
...options, ...options,
rootMargin: options.rootMargin || "300px 0px", rootMargin: options.rootMargin || "300px",
}, },
); );
@ -49,7 +45,7 @@ function useIntersectionObserver(options: IntersectionObserverInit = {}) {
}; };
}, [options]); }, [options]);
return { targetRef, isIntersecting, hasIntersected }; return { targetRef, isIntersecting };
} }
// Skeleton Component // Skeleton Component
@ -135,8 +131,8 @@ function MediaCardContent({
const [searchQuery] = useSearchQuery(); const [searchQuery] = useSearchQuery();
// Intersection observer for lazy loading // Simple intersection observer for lazy loading images
const { targetRef } = useIntersectionObserver({ const { targetRef, isIntersecting } = useIntersectionObserver({
rootMargin: "300px", rootMargin: "300px",
}); });
@ -160,158 +156,162 @@ function MediaCardContent({
} }
return ( return (
<Flare.Base <div ref={targetRef as React.RefObject<HTMLDivElement>}>
className={`group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300 focus:relative focus:z-10 ${ <Flare.Base
canLink ? "hover:bg-mediaCard-hoverBackground tabbable" : "" className={`group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300 focus:relative focus:z-10 ${
} ${closable ? "jiggle" : ""}`} canLink ? "hover:bg-mediaCard-hoverBackground tabbable" : ""
tabIndex={canLink ? 0 : -1} } ${closable ? "jiggle" : ""}`}
onKeyUp={(e) => e.key === "Enter" && e.currentTarget.click()} tabIndex={canLink ? 0 : -1}
> onKeyUp={(e) => e.key === "Enter" && e.currentTarget.click()}
<Flare.Light
flareSize={300}
cssColorVar="--colors-mediaCard-hoverAccent"
backgroundClass="bg-mediaCard-hoverBackground duration-100"
className={classNames({
"rounded-xl bg-background-main group-hover:opacity-100": canLink,
})}
/>
<Flare.Child
className={`pointer-events-auto relative mb-2 p-[0.4em] transition-transform duration-300 ${
canLink ? "group-hover:scale-95" : "opacity-60"
}`}
> >
<div <Flare.Light
className={classNames( flareSize={300}
"relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-300", cssColorVar="--colors-mediaCard-hoverAccent"
{ backgroundClass="bg-mediaCard-hoverBackground duration-100"
"group-hover:rounded-lg": canLink, className={classNames({
}, "rounded-xl bg-background-main group-hover:opacity-100": canLink,
)} })}
style={{ />
backgroundImage: media.poster <Flare.Child
? `url(${media.poster})` className={`pointer-events-auto relative mb-2 p-[0.4em] transition-transform duration-300 ${
: "url(/placeholder.png)", canLink ? "group-hover:scale-95" : "opacity-60"
}} }`}
> >
{series ? ( <div
<div className={classNames(
className={[ "relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-300",
"absolute right-2 top-2 rounded-md bg-mediaCard-badge px-2 py-1 transition-colors", {
].join(" ")} "group-hover:rounded-lg": canLink,
> },
<p )}
style={{
backgroundImage: isIntersecting
? media.poster
? `url(${media.poster})`
: "url(/placeholder.png)"
: "",
}}
>
{series ? (
<div
className={[ className={[
"text-center text-xs font-bold text-mediaCard-badgeText transition-colors", "absolute right-2 top-2 rounded-md bg-mediaCard-badge px-2 py-1 transition-colors",
closable ? "" : "group-hover:text-white",
].join(" ")} ].join(" ")}
> >
{t("media.episodeDisplay", { <p
season: series.season || 1, className={[
episode: series.episode, "text-center text-xs font-bold text-mediaCard-badgeText transition-colors",
})} closable ? "" : "group-hover:text-white",
</p> ].join(" ")}
</div> >
) : null} {t("media.episodeDisplay", {
season: series.season || 1,
{percentage !== undefined ? ( episode: series.episode,
<> })}
<div </p>
className={`absolute inset-x-0 -bottom-px pb-1 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
}`}
/>
<div
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
}`}
/>
<div className="absolute inset-x-0 bottom-0 p-3">
<div className="relative h-1 overflow-hidden rounded-full bg-mediaCard-barColor">
<div
className="absolute inset-y-0 left-0 rounded-full bg-mediaCard-barFillColor"
style={{
width: percentageString,
}}
/>
</div>
</div> </div>
</> ) : null}
) : null}
{percentage !== undefined ? (
<>
<div
className={`absolute inset-x-0 -bottom-px pb-1 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
}`}
/>
<div
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
}`}
/>
<div className="absolute inset-x-0 bottom-0 p-3">
<div className="relative h-1 overflow-hidden rounded-full bg-mediaCard-barColor">
<div
className="absolute inset-y-0 left-0 rounded-full bg-mediaCard-barFillColor"
style={{
width: percentageString,
}}
/>
</div>
</div>
</>
) : null}
{!closable && (
<div
className="absolute bookmark-button"
onClick={(e) => e.preventDefault()}
>
<MediaBookmarkButton media={media} />
</div>
)}
{searchQuery.length > 0 && !closable ? (
<div className="absolute" onClick={(e) => e.preventDefault()}>
<MediaBookmarkButton media={media} />
</div>
) : null}
<div
className={`absolute inset-0 flex items-center justify-center bg-mediaCard-badge bg-opacity-80 transition-opacity duration-500 ${
closable ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<IconPatch
clickable
className="text-2xl text-mediaCard-badgeText transition-transform hover:scale-110 duration-500"
onClick={() => closable && onClose?.()}
icon={Icons.X}
/>
</div>
</div>
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
<span>{media.title}</span>
</h1>
<div className="media-info-container justify-content-center flex flex-wrap">
<DotList className="text-xs" content={dotListContent} />
</div>
{!closable && ( {!closable && (
<div <div className="absolute bottom-0 translate-y-1 right-1">
className="absolute bookmark-button" <button
onClick={(e) => e.preventDefault()} className="media-more-button p-2"
> type="button"
<MediaBookmarkButton media={media} /> onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onShowDetails?.(media);
}}
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.ELLIPSIS}
/>
</button>
</div> </div>
)} )}
{editable && closable && (
{searchQuery.length > 0 && !closable ? ( <div className="absolute bottom-0 translate-y-1 right-1">
<div className="absolute" onClick={(e) => e.preventDefault()}> <button
<MediaBookmarkButton media={media} /> className="media-more-button p-2"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEdit?.();
}}
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.EDIT}
/>
</button>
</div> </div>
) : null} )}
</Flare.Child>
<div </Flare.Base>
className={`absolute inset-0 flex items-center justify-center bg-mediaCard-badge bg-opacity-80 transition-opacity duration-500 ${ </div>
closable ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<IconPatch
clickable
className="text-2xl text-mediaCard-badgeText transition-transform hover:scale-110 duration-500"
onClick={() => closable && onClose?.()}
icon={Icons.X}
/>
</div>
</div>
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
<span>{media.title}</span>
</h1>
<div className="media-info-container justify-content-center flex flex-wrap">
<DotList className="text-xs" content={dotListContent} />
</div>
{!closable && (
<div className="absolute bottom-0 translate-y-1 right-1">
<button
className="media-more-button p-2"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onShowDetails?.(media);
}}
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.ELLIPSIS}
/>
</button>
</div>
)}
{editable && closable && (
<div className="absolute bottom-0 translate-y-1 right-1">
<button
className="media-more-button p-2"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEdit?.();
}}
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.EDIT}
/>
</button>
</div>
)}
</Flare.Child>
</Flare.Base>
); );
} }

View file

@ -15,7 +15,6 @@ import {
useDiscoverMedia, useDiscoverMedia,
useDiscoverOptions, useDiscoverOptions,
} from "@/pages/discover/hooks/useDiscoverMedia"; } from "@/pages/discover/hooks/useDiscoverMedia";
import { useIntersectionObserver } from "@/pages/discover/hooks/useIntersectionObserver";
import { useDiscoverStore } from "@/stores/discover"; import { useDiscoverStore } from "@/stores/discover";
import { useProgressStore } from "@/stores/progress"; import { useProgressStore } from "@/stores/progress";
import { MediaItem } from "@/utils/mediaTypes"; import { MediaItem } from "@/utils/mediaTypes";
@ -114,13 +113,6 @@ export function MediaCarousel({
title: item.title || "", title: item.title || "",
})); }));
// Set up intersection observer for lazy loading
const { targetRef, isIntersecting, hasIntersected } = useIntersectionObserver(
{
rootMargin: "300px",
},
);
// Handle provider/genre selection // Handle provider/genre selection
const handleProviderChange = React.useCallback((id: string, name: string) => { const handleProviderChange = React.useCallback((id: string, name: string) => {
setSelectedProviderId(id); setSelectedProviderId(id);
@ -197,7 +189,7 @@ export function MediaCarousel({
content.type, content.type,
]); ]);
// Fetch media using our hook - only when carousel has been visible // Fetch media using our hook
const { media, sectionTitle, actualContentType } = useDiscoverMedia({ const { media, sectionTitle, actualContentType } = useDiscoverMedia({
contentType, contentType,
mediaType, mediaType,
@ -207,7 +199,6 @@ export function MediaCarousel({
providerName: selectedProviderName, providerName: selectedProviderName,
mediaTitle: selectedRecommendationTitle, mediaTitle: selectedRecommendationTitle,
isCarouselView: true, isCarouselView: true,
enabled: hasIntersected,
}); });
// Find active button // Find active button
@ -311,47 +302,8 @@ export function MediaCarousel({
actualContentType, actualContentType,
]); ]);
// Loading state
if (!isIntersecting || !sectionTitle) {
return (
<div ref={targetRef as React.RefObject<HTMLDivElement>}>
<div className="flex items-center justify-between ml-2 md:ml-8 mt-2">
<div className="flex gap-4 items-center">
<h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-5 text-balance">
{t("discover.carousel.title.loading")}
</h2>
</div>
</div>
<div className="relative overflow-hidden carousel-container md:pb-4">
<div className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8">
<div className="md:w-12" />
{Array(10)
.fill(null)
.map((_, index) => (
<div
key={`skeleton-loading-${Math.random().toString(36).substring(2)}`}
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"
>
<MediaCard
media={{
id: `skeleton-${index}`,
title: "",
poster: "",
type: isTVShow ? "show" : "movie",
}}
forceSkeleton
/>
</div>
))}
<div className="md:w-12" />
</div>
</div>
</div>
);
}
return ( return (
<div ref={targetRef as React.RefObject<HTMLDivElement>}> <div>
<div className="flex items-center justify-between ml-2 md:ml-8 mt-2"> <div className="flex items-center justify-between ml-2 md:ml-8 mt-2">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">