mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
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:
parent
5e1bd09af5
commit
be6aec2c86
2 changed files with 152 additions and 200 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue