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 { Icon, Icons } from "../Icon";
// Intersection Observer Hook
// Simple Intersection Observer Hook
function useIntersectionObserver(options: IntersectionObserverInit = {}) {
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) {
setHasIntersected(true);
}
},
{
...options,
rootMargin: options.rootMargin || "300px 0px",
rootMargin: options.rootMargin || "300px",
},
);
@ -49,7 +45,7 @@ function useIntersectionObserver(options: IntersectionObserverInit = {}) {
};
}, [options]);
return { targetRef, isIntersecting, hasIntersected };
return { targetRef, isIntersecting };
}
// Skeleton Component
@ -135,8 +131,8 @@ function MediaCardContent({
const [searchQuery] = useSearchQuery();
// Intersection observer for lazy loading
const { targetRef } = useIntersectionObserver({
// Simple intersection observer for lazy loading images
const { targetRef, isIntersecting } = useIntersectionObserver({
rootMargin: "300px",
});
@ -160,158 +156,162 @@ function MediaCardContent({
}
return (
<Flare.Base
className={`group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300 focus:relative focus:z-10 ${
canLink ? "hover:bg-mediaCard-hoverBackground tabbable" : ""
} ${closable ? "jiggle" : ""}`}
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 ref={targetRef as React.RefObject<HTMLDivElement>}>
<Flare.Base
className={`group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300 focus:relative focus:z-10 ${
canLink ? "hover:bg-mediaCard-hoverBackground tabbable" : ""
} ${closable ? "jiggle" : ""}`}
tabIndex={canLink ? 0 : -1}
onKeyUp={(e) => e.key === "Enter" && e.currentTarget.click()}
>
<div
className={classNames(
"relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-300",
{
"group-hover:rounded-lg": canLink,
},
)}
style={{
backgroundImage: media.poster
? `url(${media.poster})`
: "url(/placeholder.png)",
}}
<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"
}`}
>
{series ? (
<div
className={[
"absolute right-2 top-2 rounded-md bg-mediaCard-badge px-2 py-1 transition-colors",
].join(" ")}
>
<p
<div
className={classNames(
"relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-300",
{
"group-hover:rounded-lg": canLink,
},
)}
style={{
backgroundImage: isIntersecting
? media.poster
? `url(${media.poster})`
: "url(/placeholder.png)"
: "",
}}
>
{series ? (
<div
className={[
"text-center text-xs font-bold text-mediaCard-badgeText transition-colors",
closable ? "" : "group-hover:text-white",
"absolute right-2 top-2 rounded-md bg-mediaCard-badge px-2 py-1 transition-colors",
].join(" ")}
>
{t("media.episodeDisplay", {
season: series.season || 1,
episode: series.episode,
})}
</p>
</div>
) : 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>
<p
className={[
"text-center text-xs font-bold text-mediaCard-badgeText transition-colors",
closable ? "" : "group-hover:text-white",
].join(" ")}
>
{t("media.episodeDisplay", {
season: series.season || 1,
episode: series.episode,
})}
</p>
</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 && (
<div
className="absolute bookmark-button"
onClick={(e) => e.preventDefault()}
>
<MediaBookmarkButton media={media} />
<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>
)}
{searchQuery.length > 0 && !closable ? (
<div className="absolute" onClick={(e) => e.preventDefault()}>
<MediaBookmarkButton media={media} />
{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>
) : 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 && (
<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>
)}
</Flare.Child>
</Flare.Base>
</div>
);
}

View file

@ -15,7 +15,6 @@ import {
useDiscoverMedia,
useDiscoverOptions,
} from "@/pages/discover/hooks/useDiscoverMedia";
import { useIntersectionObserver } from "@/pages/discover/hooks/useIntersectionObserver";
import { useDiscoverStore } from "@/stores/discover";
import { useProgressStore } from "@/stores/progress";
import { MediaItem } from "@/utils/mediaTypes";
@ -114,13 +113,6 @@ export function MediaCarousel({
title: item.title || "",
}));
// Set up intersection observer for lazy loading
const { targetRef, isIntersecting, hasIntersected } = useIntersectionObserver(
{
rootMargin: "300px",
},
);
// Handle provider/genre selection
const handleProviderChange = React.useCallback((id: string, name: string) => {
setSelectedProviderId(id);
@ -197,7 +189,7 @@ export function MediaCarousel({
content.type,
]);
// Fetch media using our hook - only when carousel has been visible
// Fetch media using our hook
const { media, sectionTitle, actualContentType } = useDiscoverMedia({
contentType,
mediaType,
@ -207,7 +199,6 @@ export function MediaCarousel({
providerName: selectedProviderName,
mediaTitle: selectedRecommendationTitle,
isCarouselView: true,
enabled: hasIntersected,
});
// Find active button
@ -311,47 +302,8 @@ export function MediaCarousel({
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 (
<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 flex-col">
<div className="flex items-center gap-4">