mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 17:55:33 +00:00
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.
This commit is contained in:
parent
1b5231ae72
commit
8c6d5031d5
4 changed files with 122 additions and 85 deletions
|
|
@ -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<Element | null>(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 (
|
||||
<div className="group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300">
|
||||
<div className="pointer-events-auto relative mb-2 p-[0.4em] transition-transform duration-300">
|
||||
<div className="animate-pulse">
|
||||
{/* Poster skeleton - matches MediaCard poster dimensions exactly */}
|
||||
<div className="relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground" />
|
||||
|
||||
{/* Title skeleton - matches MediaCard title dimensions */}
|
||||
<div className="mb-1">
|
||||
<div className="h-4 bg-mediaCard-hoverBackground rounded w-full mb-1" />
|
||||
<div className="h-4 bg-mediaCard-hoverBackground rounded w-3/4 mb-1" />
|
||||
<div className="h-4 bg-mediaCard-hoverBackground rounded w-1/2" />
|
||||
</div>
|
||||
|
||||
{/* Dot list skeleton - matches MediaCard dot list */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-3 bg-mediaCard-hoverBackground rounded w-12" />
|
||||
<div className="h-1 w-1 bg-mediaCard-hoverBackground rounded-full" />
|
||||
<div className="h-3 bg-mediaCard-hoverBackground rounded w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div ref={targetRef as React.RefObject<HTMLDivElement>}>
|
||||
<MediaCardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<>
|
||||
<MediaCardContent {...props} onShowDetails={handleShowDetails} />
|
||||
<MediaCardContent
|
||||
{...props}
|
||||
onShowDetails={handleShowDetails}
|
||||
forceSkeleton={forceSkeleton}
|
||||
/>
|
||||
{detailsData && <DetailsModal id="details" data={detailsData} />}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div style={{ display: isActive ? "block" : "none" }}>
|
||||
{hasLoaded ? children : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div 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">
|
||||
<div className="group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300">
|
||||
<div className="pointer-events-auto relative mb-2 p-[0.4em] transition-transform duration-300">
|
||||
<div className="animate-pulse">
|
||||
{/* Poster skeleton - matches MediaCard poster dimensions exactly */}
|
||||
<div className="relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground" />
|
||||
|
||||
{/* Title skeleton - matches MediaCard title dimensions */}
|
||||
<div className="mb-1">
|
||||
<div className="h-4 bg-mediaCard-hoverBackground rounded w-full mb-1" />
|
||||
<div className="h-4 bg-mediaCard-hoverBackground rounded w-3/4 mb-1" />
|
||||
<div className="h-4 bg-mediaCard-hoverBackground rounded w-1/2" />
|
||||
</div>
|
||||
|
||||
{/* Dot list skeleton - matches MediaCard dot list */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-3 bg-mediaCard-hoverBackground rounded w-12" />
|
||||
<div className="h-1 w-1 bg-mediaCard-hoverBackground rounded-full" />
|
||||
<div className="h-3 bg-mediaCard-hoverBackground rounded w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreCard({ link }: { link: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -364,10 +324,21 @@ export function MediaCarousel({
|
|||
<div className="md:w-12" />
|
||||
{Array(10)
|
||||
.fill(null)
|
||||
.map(() => (
|
||||
<MediaCardSkeleton
|
||||
.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>
|
||||
|
|
@ -586,10 +557,21 @@ export function MediaCarousel({
|
|||
))
|
||||
: Array(10)
|
||||
.fill(null)
|
||||
.map((_, _i) => (
|
||||
<MediaCardSkeleton
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={`skeleton-${categorySlug}-${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>
|
||||
))}
|
||||
|
||||
{moreContent && generatedMoreLink && (
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
|
||||
<WideContainer ultraWide classNames="!px-0">
|
||||
{/* Movies Tab */}
|
||||
<LazyTabContent isActive={isMoviesTab}>
|
||||
<div style={{ display: isMoviesTab ? "block" : "none" }}>
|
||||
{renderMoviesContent()}
|
||||
</LazyTabContent>
|
||||
</div>
|
||||
|
||||
{/* TV Shows Tab */}
|
||||
<LazyTabContent isActive={isTVShowsTab}>
|
||||
<div style={{ display: isTVShowsTab ? "block" : "none" }}>
|
||||
{renderTVShowsContent()}
|
||||
</LazyTabContent>
|
||||
</div>
|
||||
|
||||
{/* Editor Picks Tab */}
|
||||
<LazyTabContent isActive={isEditorPicksTab}>
|
||||
<div style={{ display: isEditorPicksTab ? "block" : "none" }}>
|
||||
{renderEditorPicksContent()}
|
||||
</LazyTabContent>
|
||||
</div>
|
||||
</WideContainer>
|
||||
|
||||
{/* View All Button */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue