pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

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.

Pas 8c6d5031 1b5231ae

+122 -85
+88 -3
src/components/media/MediaCard.tsx
··· 1 1 // I'm sorry this is so confusing 😭 2 2 3 3 import classNames from "classnames"; 4 - import { useCallback, useState } from "react"; 4 + import { useCallback, useEffect, useRef, useState } from "react"; 5 5 import { useTranslation } from "react-i18next"; 6 6 import { Link } from "react-router-dom"; 7 7 ··· 18 18 import { Icon, Icons } from "../Icon"; 19 19 import { DetailsModal } from "../overlays/detailsModal"; 20 20 21 + // Intersection Observer Hook 22 + function useIntersectionObserver(options: IntersectionObserverInit = {}) { 23 + const [isIntersecting, setIsIntersecting] = useState(false); 24 + const [hasIntersected, setHasIntersected] = useState(false); 25 + const targetRef = useRef<Element | null>(null); 26 + 27 + useEffect(() => { 28 + const observer = new IntersectionObserver( 29 + ([entry]) => { 30 + setIsIntersecting(entry.isIntersecting); 31 + if (entry.isIntersecting) { 32 + setHasIntersected(true); 33 + } 34 + }, 35 + { 36 + ...options, 37 + rootMargin: options.rootMargin || "300px 0px", 38 + }, 39 + ); 40 + 41 + const currentTarget = targetRef.current; 42 + if (currentTarget) { 43 + observer.observe(currentTarget); 44 + } 45 + 46 + return () => { 47 + if (currentTarget) { 48 + observer.unobserve(currentTarget); 49 + } 50 + }; 51 + }, [options]); 52 + 53 + return { targetRef, isIntersecting, hasIntersected }; 54 + } 55 + 56 + // Skeleton Component 57 + function MediaCardSkeleton() { 58 + return ( 59 + <div className="group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300"> 60 + <div className="pointer-events-auto relative mb-2 p-[0.4em] transition-transform duration-300"> 61 + <div className="animate-pulse"> 62 + {/* Poster skeleton - matches MediaCard poster dimensions exactly */} 63 + <div className="relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground" /> 64 + 65 + {/* Title skeleton - matches MediaCard title dimensions */} 66 + <div className="mb-1"> 67 + <div className="h-4 bg-mediaCard-hoverBackground rounded w-full mb-1" /> 68 + <div className="h-4 bg-mediaCard-hoverBackground rounded w-3/4 mb-1" /> 69 + <div className="h-4 bg-mediaCard-hoverBackground rounded w-1/2" /> 70 + </div> 71 + 72 + {/* Dot list skeleton - matches MediaCard dot list */} 73 + <div className="flex items-center gap-1"> 74 + <div className="h-3 bg-mediaCard-hoverBackground rounded w-12" /> 75 + <div className="h-1 w-1 bg-mediaCard-hoverBackground rounded-full" /> 76 + <div className="h-3 bg-mediaCard-hoverBackground rounded w-8" /> 77 + </div> 78 + </div> 79 + </div> 80 + </div> 81 + ); 82 + } 83 + 21 84 export interface MediaCardProps { 22 85 media: MediaItem; 23 86 linkable?: boolean; ··· 31 94 closable?: boolean; 32 95 onClose?: () => void; 33 96 onShowDetails?: (media: MediaItem) => void; 97 + forceSkeleton?: boolean; 34 98 } 35 99 36 100 function checkReleased(media: MediaItem): boolean { ··· 55 119 closable, 56 120 onClose, 57 121 onShowDetails, 122 + forceSkeleton, 58 123 }: MediaCardProps) { 59 124 const { t } = useTranslation(); 60 125 const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`; ··· 71 136 (state) => state.enableLowPerformanceMode, 72 137 ); 73 138 139 + // Intersection observer for lazy loading 140 + const { targetRef } = useIntersectionObserver({ 141 + rootMargin: "300px", 142 + }); 143 + 144 + // Show skeleton if forced or if media hasn't loaded yet (empty title/poster) 145 + const shouldShowSkeleton = forceSkeleton || (!media.title && !media.poster); 146 + 147 + if (shouldShowSkeleton) { 148 + return ( 149 + <div ref={targetRef as React.RefObject<HTMLDivElement>}> 150 + <MediaCardSkeleton /> 151 + </div> 152 + ); 153 + } 154 + 74 155 if (isReleased() && media.year) { 75 156 dotListContent.push(media.year.toFixed()); 76 157 } ··· 218 299 } 219 300 220 301 export function MediaCard(props: MediaCardProps) { 221 - const { media, onShowDetails } = props; 302 + const { media, onShowDetails, forceSkeleton } = props; 222 303 const [detailsData, setDetailsData] = useState<{ 223 304 id: number; 224 305 type: "movie" | "show"; ··· 275 356 276 357 const content = ( 277 358 <> 278 - <MediaCardContent {...props} onShowDetails={handleShowDetails} /> 359 + <MediaCardContent 360 + {...props} 361 + onShowDetails={handleShowDetails} 362 + forceSkeleton={forceSkeleton} 363 + /> 279 364 {detailsData && <DetailsModal id="details" data={detailsData} />} 280 365 </> 281 366 );
-29
src/pages/discover/components/LazyTabContent.tsx
··· 1 - import { ReactNode, useEffect, useState } from "react"; 2 - 3 - interface LazyTabContentProps { 4 - isActive: boolean; 5 - children: ReactNode; 6 - preloadWhenInactive?: boolean; 7 - } 8 - 9 - export function LazyTabContent({ 10 - isActive, 11 - children, 12 - preloadWhenInactive = false, 13 - }: LazyTabContentProps) { 14 - const [hasLoaded, setHasLoaded] = useState(false); 15 - 16 - useEffect(() => { 17 - // Load content when tab becomes active or if preload is enabled 18 - if (isActive || preloadWhenInactive) { 19 - setHasLoaded(true); 20 - } 21 - }, [isActive, preloadWhenInactive]); 22 - 23 - // Only render children if the tab has been loaded 24 - return ( 25 - <div style={{ display: isActive ? "block" : "none" }}> 26 - {hasLoaded ? children : null} 27 - </div> 28 - ); 29 - }
+28 -46
src/pages/discover/components/MediaCarousel.tsx
··· 23 23 import { CarouselNavButtons } from "./CarouselNavButtons"; 24 24 25 25 interface ContentConfig { 26 - /** Primary content type to fetch */ 27 26 type: DiscoverContentType; 28 - /** Fallback content type if primary fails */ 29 27 fallback?: DiscoverContentType; 30 28 } 31 29 32 30 interface MediaCarouselProps { 33 - /** Content configuration for the carousel */ 34 31 content: ContentConfig; 35 - /** Whether this is a TV show carousel */ 36 32 isTVShow: boolean; 37 - /** Refs for carousel navigation */ 38 33 carouselRefs: React.MutableRefObject<{ 39 34 [key: string]: HTMLDivElement | null; 40 35 }>; 41 - /** Callback when media details should be shown */ 42 36 onShowDetails?: (media: MediaItem) => void; 43 - /** Whether to show more content button/link */ 44 37 moreContent?: boolean; 45 - /** Custom more content link */ 46 38 moreLink?: string; 47 - /** Whether to show provider selection */ 48 39 showProviders?: boolean; 49 - /** Whether to show genre selection */ 50 40 showGenres?: boolean; 51 - /** Whether to show recommendations */ 52 41 showRecommendations?: boolean; 53 - } 54 - 55 - function MediaCardSkeleton() { 56 - return ( 57 - <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"> 58 - <div className="group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300"> 59 - <div className="pointer-events-auto relative mb-2 p-[0.4em] transition-transform duration-300"> 60 - <div className="animate-pulse"> 61 - {/* Poster skeleton - matches MediaCard poster dimensions exactly */} 62 - <div className="relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground" /> 63 - 64 - {/* Title skeleton - matches MediaCard title dimensions */} 65 - <div className="mb-1"> 66 - <div className="h-4 bg-mediaCard-hoverBackground rounded w-full mb-1" /> 67 - <div className="h-4 bg-mediaCard-hoverBackground rounded w-3/4 mb-1" /> 68 - <div className="h-4 bg-mediaCard-hoverBackground rounded w-1/2" /> 69 - </div> 70 - 71 - {/* Dot list skeleton - matches MediaCard dot list */} 72 - <div className="flex items-center gap-1"> 73 - <div className="h-3 bg-mediaCard-hoverBackground rounded w-12" /> 74 - <div className="h-1 w-1 bg-mediaCard-hoverBackground rounded-full" /> 75 - <div className="h-3 bg-mediaCard-hoverBackground rounded w-8" /> 76 - </div> 77 - </div> 78 - </div> 79 - </div> 80 - </div> 81 - ); 82 42 } 83 43 84 44 function MoreCard({ link }: { link: string }) { ··· 364 324 <div className="md:w-12" /> 365 325 {Array(10) 366 326 .fill(null) 367 - .map(() => ( 368 - <MediaCardSkeleton 327 + .map((_, index) => ( 328 + <div 369 329 key={`skeleton-loading-${Math.random().toString(36).substring(2)}`} 370 - /> 330 + 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" 331 + > 332 + <MediaCard 333 + media={{ 334 + id: `skeleton-${index}`, 335 + title: "", 336 + poster: "", 337 + type: isTVShow ? "show" : "movie", 338 + }} 339 + forceSkeleton 340 + /> 341 + </div> 371 342 ))} 372 343 <div className="md:w-12" /> 373 344 </div> ··· 586 557 )) 587 558 : Array(10) 588 559 .fill(null) 589 - .map((_, _i) => ( 590 - <MediaCardSkeleton 560 + .map((_, index) => ( 561 + <div 591 562 key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(2)}`} 592 - /> 563 + 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" 564 + > 565 + <MediaCard 566 + media={{ 567 + id: `skeleton-${index}`, 568 + title: "", 569 + poster: "", 570 + type: isTVShow ? "show" : "movie", 571 + }} 572 + forceSkeleton 573 + /> 574 + </div> 593 575 ))} 594 576 595 577 {moreContent && generatedMoreLink && (
+6 -7
src/pages/discover/discoverContent.tsx
··· 13 13 14 14 import { DiscoverNavigation } from "./components/DiscoverNavigation"; 15 15 import type { FeaturedMedia } from "./components/FeaturedCarousel"; 16 - import { LazyTabContent } from "./components/LazyTabContent"; 17 16 import { MediaCarousel } from "./components/MediaCarousel"; 18 17 import { ScrollToTopButton } from "./components/ScrollToTopButton"; 19 18 ··· 212 211 213 212 <WideContainer ultraWide classNames="!px-0"> 214 213 {/* Movies Tab */} 215 - <LazyTabContent isActive={isMoviesTab}> 214 + <div style={{ display: isMoviesTab ? "block" : "none" }}> 216 215 {renderMoviesContent()} 217 - </LazyTabContent> 216 + </div> 218 217 219 218 {/* TV Shows Tab */} 220 - <LazyTabContent isActive={isTVShowsTab}> 219 + <div style={{ display: isTVShowsTab ? "block" : "none" }}> 221 220 {renderTVShowsContent()} 222 - </LazyTabContent> 221 + </div> 223 222 224 223 {/* Editor Picks Tab */} 225 - <LazyTabContent isActive={isEditorPicksTab}> 224 + <div style={{ display: isEditorPicksTab ? "block" : "none" }}> 226 225 {renderEditorPicksContent()} 227 - </LazyTabContent> 226 + </div> 228 227 </WideContainer> 229 228 230 229 {/* View All Button */}