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

Add lazy loading for media carousels using Intersection Observer

Introduces a reusable useIntersectionObserver hook and a LazyMediaCarousel component to defer rendering of carousels until they are near the viewport. Updates discoverContent and AllMovieLists to use LazyMediaCarousel, improving performance by only loading carousels as needed. Priority carousels (e.g., top of page) are loaded immediately.

Pas ebdb931d b464c219

+277 -110
+49
src/hooks/useIntersectionObserver.ts
··· 1 + import { useEffect, useRef, useState } from "react"; 2 + 3 + interface UseIntersectionObserverOptions { 4 + threshold?: number; 5 + root?: Element | null; 6 + rootMargin?: string; 7 + } 8 + 9 + export function useIntersectionObserver<T extends HTMLElement = HTMLDivElement>( 10 + options: UseIntersectionObserverOptions = {}, 11 + ) { 12 + const { threshold = 0.1, root = null, rootMargin = "0px" } = options; 13 + const [isIntersecting, setIsIntersecting] = useState(false); 14 + const [hasIntersected, setHasIntersected] = useState(false); 15 + const elementRef = useRef<T | null>(null); 16 + 17 + useEffect(() => { 18 + const element = elementRef.current; 19 + if (!element) return; 20 + 21 + const observer = new IntersectionObserver( 22 + ([entry]) => { 23 + const isElementIntersecting = entry.isIntersecting; 24 + setIsIntersecting(isElementIntersecting); 25 + 26 + if (isElementIntersecting && !hasIntersected) { 27 + setHasIntersected(true); 28 + } 29 + }, 30 + { 31 + threshold, 32 + root, 33 + rootMargin, 34 + }, 35 + ); 36 + 37 + observer.observe(element); 38 + 39 + return () => { 40 + observer.unobserve(element); 41 + }; 42 + }, [threshold, root, rootMargin, hasIntersected]); 43 + 44 + return { 45 + ref: elementRef, 46 + isIntersecting, 47 + hasIntersected, 48 + }; 49 + }
+5 -3
src/pages/discover/AllMovieLists.tsx
··· 19 19 import { useOverlayStack } from "@/stores/interface/overlayStack"; 20 20 import { MediaItem } from "@/utils/mediaTypes"; 21 21 22 - import { MediaCarousel } from "./components/MediaCarousel"; 22 + import { LazyMediaCarousel } from "./components/LazyMediaCarousel"; 23 23 24 24 export function DiscoverMore() { 25 25 const [curatedLists, setCuratedLists] = useState<CuratedMovieList[]>([]); ··· 107 107 <WideContainer ultraWide> 108 108 {/* Latest Movies */} 109 109 <div className="relative"> 110 - <MediaCarousel 110 + <LazyMediaCarousel 111 111 content={{ type: "latest", fallback: "nowPlaying" }} 112 112 isTVShow={false} 113 113 carouselRefs={carouselRefs} 114 114 onShowDetails={handleShowDetails} 115 + priority // Load immediately as first carousel 115 116 /> 116 117 </div> 117 118 118 119 {/* Top Rated Movies */} 119 120 <div className="relative"> 120 - <MediaCarousel 121 + <LazyMediaCarousel 121 122 content={{ type: "latest4k", fallback: "topRated" }} 122 123 isTVShow={false} 123 124 carouselRefs={carouselRefs} 124 125 onShowDetails={handleShowDetails} 126 + priority // Load immediately as second carousel 125 127 /> 126 128 </div> 127 129
+70
src/pages/discover/components/LazyMediaCarousel.tsx
··· 1 + import React from "react"; 2 + 3 + import { useIntersectionObserver } from "@/hooks/useIntersectionObserver"; 4 + import { MediaItem } from "@/utils/mediaTypes"; 5 + 6 + import { MediaCarousel } from "./MediaCarousel"; 7 + import { DiscoverContentType } from "../types/discover"; 8 + 9 + interface ContentConfig { 10 + type: DiscoverContentType; 11 + fallback?: DiscoverContentType; 12 + } 13 + 14 + interface LazyMediaCarouselProps { 15 + content: ContentConfig; 16 + isTVShow: boolean; 17 + carouselRefs: React.MutableRefObject<{ 18 + [key: string]: HTMLDivElement | null; 19 + }>; 20 + onShowDetails?: (media: MediaItem) => void; 21 + moreContent?: boolean; 22 + moreLink?: string; 23 + showProviders?: boolean; 24 + showGenres?: boolean; 25 + showRecommendations?: boolean; 26 + priority?: boolean; // For carousels that should load immediately (e.g., first few) 27 + } 28 + 29 + export function LazyMediaCarousel({ 30 + content, 31 + isTVShow, 32 + carouselRefs, 33 + onShowDetails, 34 + moreContent, 35 + moreLink, 36 + showProviders = false, 37 + showGenres = false, 38 + showRecommendations = false, 39 + priority = false, 40 + }: LazyMediaCarouselProps) { 41 + const { ref, hasIntersected } = useIntersectionObserver<HTMLDivElement>({ 42 + threshold: 0.1, 43 + rootMargin: "50px", // Start loading when carousel is 50px from viewport 44 + }); 45 + 46 + // Always render if priority is true (for top carousels) 47 + // Otherwise, only render when intersected 48 + const shouldRender = priority || hasIntersected; 49 + 50 + return ( 51 + <div ref={ref}> 52 + {shouldRender ? ( 53 + <MediaCarousel 54 + content={content} 55 + isTVShow={isTVShow} 56 + carouselRefs={carouselRefs} 57 + onShowDetails={onShowDetails} 58 + moreContent={moreContent} 59 + moreLink={moreLink} 60 + showProviders={showProviders} 61 + showGenres={showGenres} 62 + showRecommendations={showRecommendations} 63 + /> 64 + ) : ( 65 + // Placeholder with similar height to prevent layout shift 66 + <div className="h-[20rem]" /> 67 + )} 68 + </div> 69 + ); 70 + }
+153 -107
src/pages/discover/discoverContent.tsx
··· 12 12 13 13 import { DiscoverNavigation } from "./components/DiscoverNavigation"; 14 14 import type { FeaturedMedia } from "./components/FeaturedCarousel"; 15 - import { MediaCarousel } from "./components/MediaCarousel"; 15 + import { LazyMediaCarousel } from "./components/LazyMediaCarousel"; 16 16 import { ScrollToTopButton } from "./components/ScrollToTopButton"; 17 17 18 18 export function DiscoverContent() { ··· 47 47 48 48 // Render Movies content with lazy loading 49 49 const renderMoviesContent = () => { 50 - return ( 51 - <> 52 - {/* Movie Recommendations - only show if there are movie progress items */} 53 - {movieProgressItems.length > 0 && ( 54 - <MediaCarousel 55 - content={{ type: "recommendations" }} 56 - isTVShow={false} 57 - carouselRefs={carouselRefs} 58 - onShowDetails={handleShowDetails} 59 - moreContent 60 - showRecommendations 61 - /> 62 - )} 50 + const carousels = []; 63 51 64 - {/* Latest Releases */} 65 - <MediaCarousel 66 - content={{ type: "latest", fallback: "nowPlaying" }} 52 + // Movie Recommendations - only show if there are movie progress items 53 + if (movieProgressItems.length > 0) { 54 + carousels.push( 55 + <LazyMediaCarousel 56 + key="movie-recommendations" 57 + content={{ type: "recommendations" }} 67 58 isTVShow={false} 68 59 carouselRefs={carouselRefs} 69 60 onShowDetails={handleShowDetails} 70 61 moreContent 71 - /> 62 + showRecommendations 63 + priority={carousels.length < 2} // First 2 carousels load immediately 64 + />, 65 + ); 66 + } 72 67 73 - {/* 4K Releases */} 74 - <MediaCarousel 75 - content={{ type: "latest4k", fallback: "popular" }} 76 - isTVShow={false} 77 - carouselRefs={carouselRefs} 78 - onShowDetails={handleShowDetails} 79 - moreContent 80 - /> 68 + // Latest Releases 69 + carousels.push( 70 + <LazyMediaCarousel 71 + key="movie-latest" 72 + content={{ type: "latest", fallback: "nowPlaying" }} 73 + isTVShow={false} 74 + carouselRefs={carouselRefs} 75 + onShowDetails={handleShowDetails} 76 + moreContent 77 + priority={carousels.length < 2} 78 + />, 79 + ); 81 80 82 - {/* Top Rated */} 83 - <MediaCarousel 84 - content={{ type: "topRated" }} 85 - isTVShow={false} 86 - carouselRefs={carouselRefs} 87 - onShowDetails={handleShowDetails} 88 - moreContent 89 - /> 81 + // 4K Releases 82 + carousels.push( 83 + <LazyMediaCarousel 84 + key="movie-4k" 85 + content={{ type: "latest4k", fallback: "popular" }} 86 + isTVShow={false} 87 + carouselRefs={carouselRefs} 88 + onShowDetails={handleShowDetails} 89 + moreContent 90 + priority={carousels.length < 2} 91 + />, 92 + ); 90 93 91 - {/* Provider Movies */} 92 - <MediaCarousel 93 - content={{ type: "provider" }} 94 - isTVShow={false} 95 - carouselRefs={carouselRefs} 96 - onShowDetails={handleShowDetails} 97 - showProviders 98 - moreContent 99 - /> 94 + // Top Rated 95 + carousels.push( 96 + <LazyMediaCarousel 97 + key="movie-top-rated" 98 + content={{ type: "topRated" }} 99 + isTVShow={false} 100 + carouselRefs={carouselRefs} 101 + onShowDetails={handleShowDetails} 102 + moreContent 103 + priority={carousels.length < 2} 104 + />, 105 + ); 106 + 107 + // Provider Movies 108 + carousels.push( 109 + <LazyMediaCarousel 110 + key="movie-providers" 111 + content={{ type: "provider" }} 112 + isTVShow={false} 113 + carouselRefs={carouselRefs} 114 + onShowDetails={handleShowDetails} 115 + showProviders 116 + moreContent 117 + />, 118 + ); 100 119 101 - {/* Genre Movies */} 102 - <MediaCarousel 103 - content={{ type: "genre" }} 104 - isTVShow={false} 105 - carouselRefs={carouselRefs} 106 - onShowDetails={handleShowDetails} 107 - showGenres 108 - moreContent 109 - /> 110 - </> 120 + // Genre Movies 121 + carousels.push( 122 + <LazyMediaCarousel 123 + key="movie-genres" 124 + content={{ type: "genre" }} 125 + isTVShow={false} 126 + carouselRefs={carouselRefs} 127 + onShowDetails={handleShowDetails} 128 + showGenres 129 + moreContent 130 + />, 111 131 ); 132 + 133 + return carousels; 112 134 }; 113 135 114 136 // Render TV Shows content with lazy loading 115 137 const renderTVShowsContent = () => { 116 - return ( 117 - <> 118 - {/* TV Show Recommendations - only show if there are TV show progress items */} 119 - {tvProgressItems.length > 0 && ( 120 - <MediaCarousel 121 - content={{ type: "recommendations" }} 122 - isTVShow 123 - carouselRefs={carouselRefs} 124 - onShowDetails={handleShowDetails} 125 - moreContent 126 - showRecommendations 127 - /> 128 - )} 138 + const carousels = []; 129 139 130 - {/* On Air */} 131 - <MediaCarousel 132 - content={{ type: "latesttv", fallback: "onTheAir" }} 140 + // TV Show Recommendations - only show if there are TV show progress items 141 + if (tvProgressItems.length > 0) { 142 + carousels.push( 143 + <LazyMediaCarousel 144 + key="tv-recommendations" 145 + content={{ type: "recommendations" }} 133 146 isTVShow 134 147 carouselRefs={carouselRefs} 135 148 onShowDetails={handleShowDetails} 136 149 moreContent 137 - /> 150 + showRecommendations 151 + priority={carousels.length < 2} // First 2 carousels load immediately 152 + />, 153 + ); 154 + } 138 155 139 - {/* Top Rated */} 140 - <MediaCarousel 141 - content={{ type: "topRated" }} 142 - isTVShow 143 - carouselRefs={carouselRefs} 144 - onShowDetails={handleShowDetails} 145 - moreContent 146 - /> 156 + // On Air 157 + carousels.push( 158 + <LazyMediaCarousel 159 + key="tv-on-air" 160 + content={{ type: "latesttv", fallback: "onTheAir" }} 161 + isTVShow 162 + carouselRefs={carouselRefs} 163 + onShowDetails={handleShowDetails} 164 + moreContent 165 + priority={carousels.length < 2} 166 + />, 167 + ); 147 168 148 - {/* Popular */} 149 - <MediaCarousel 150 - content={{ type: "popular" }} 151 - isTVShow 152 - carouselRefs={carouselRefs} 153 - onShowDetails={handleShowDetails} 154 - moreContent 155 - /> 169 + // Top Rated 170 + carousels.push( 171 + <LazyMediaCarousel 172 + key="tv-top-rated" 173 + content={{ type: "topRated" }} 174 + isTVShow 175 + carouselRefs={carouselRefs} 176 + onShowDetails={handleShowDetails} 177 + moreContent 178 + priority={carousels.length < 2} 179 + />, 180 + ); 156 181 157 - {/* Provider TV Shows */} 158 - <MediaCarousel 159 - content={{ type: "provider" }} 160 - isTVShow 161 - carouselRefs={carouselRefs} 162 - onShowDetails={handleShowDetails} 163 - showProviders 164 - moreContent 165 - /> 182 + // Popular 183 + carousels.push( 184 + <LazyMediaCarousel 185 + key="tv-popular" 186 + content={{ type: "popular" }} 187 + isTVShow 188 + carouselRefs={carouselRefs} 189 + onShowDetails={handleShowDetails} 190 + moreContent 191 + priority={carousels.length < 2} 192 + />, 193 + ); 194 + 195 + // Provider TV Shows 196 + carousels.push( 197 + <LazyMediaCarousel 198 + key="tv-providers" 199 + content={{ type: "provider" }} 200 + isTVShow 201 + carouselRefs={carouselRefs} 202 + onShowDetails={handleShowDetails} 203 + showProviders 204 + moreContent 205 + />, 206 + ); 166 207 167 - {/* Genre TV Shows */} 168 - <MediaCarousel 169 - content={{ type: "genre" }} 170 - isTVShow 171 - carouselRefs={carouselRefs} 172 - onShowDetails={handleShowDetails} 173 - showGenres 174 - moreContent 175 - /> 176 - </> 208 + // Genre TV Shows 209 + carousels.push( 210 + <LazyMediaCarousel 211 + key="tv-genres" 212 + content={{ type: "genre" }} 213 + isTVShow 214 + carouselRefs={carouselRefs} 215 + onShowDetails={handleShowDetails} 216 + showGenres 217 + moreContent 218 + />, 177 219 ); 220 + 221 + return carousels; 178 222 }; 179 223 180 224 // Render Editor Picks content 181 225 const renderEditorPicksContent = () => { 182 226 return ( 183 227 <> 184 - <MediaCarousel 228 + <LazyMediaCarousel 185 229 content={{ type: "editorPicks" }} 186 230 isTVShow={false} 187 231 carouselRefs={carouselRefs} 188 232 onShowDetails={handleShowDetails} 189 233 moreContent 234 + priority // Editor picks load immediately since they're the main content 190 235 /> 191 - <MediaCarousel 236 + <LazyMediaCarousel 192 237 content={{ type: "editorPicks" }} 193 238 isTVShow 194 239 carouselRefs={carouselRefs} 195 240 onShowDetails={handleShowDetails} 196 241 moreContent 242 + priority // Editor picks load immediately since they're the main content 197 243 /> 198 244 </> 199 245 );