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.

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.

Pas be6aec2c 5e1bd09a

+148 -196
+146 -146
src/components/media/MediaCard.tsx
··· 17 17 import { IconPatch } from "../buttons/IconPatch"; 18 18 import { Icon, Icons } from "../Icon"; 19 19 20 - // Intersection Observer Hook 20 + // Simple Intersection Observer Hook 21 21 function useIntersectionObserver(options: IntersectionObserverInit = {}) { 22 22 const [isIntersecting, setIsIntersecting] = useState(false); 23 - const [hasIntersected, setHasIntersected] = useState(false); 24 23 const targetRef = useRef<Element | null>(null); 25 24 26 25 useEffect(() => { 27 26 const observer = new IntersectionObserver( 28 27 ([entry]) => { 29 28 setIsIntersecting(entry.isIntersecting); 30 - if (entry.isIntersecting) { 31 - setHasIntersected(true); 32 - } 33 29 }, 34 30 { 35 31 ...options, 36 - rootMargin: options.rootMargin || "300px 0px", 32 + rootMargin: options.rootMargin || "300px", 37 33 }, 38 34 ); 39 35 ··· 49 45 }; 50 46 }, [options]); 51 47 52 - return { targetRef, isIntersecting, hasIntersected }; 48 + return { targetRef, isIntersecting }; 53 49 } 54 50 55 51 // Skeleton Component ··· 135 131 136 132 const [searchQuery] = useSearchQuery(); 137 133 138 - // Intersection observer for lazy loading 139 - const { targetRef } = useIntersectionObserver({ 134 + // Simple intersection observer for lazy loading images 135 + const { targetRef, isIntersecting } = useIntersectionObserver({ 140 136 rootMargin: "300px", 141 137 }); 142 138 ··· 160 156 } 161 157 162 158 return ( 163 - <Flare.Base 164 - className={`group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300 focus:relative focus:z-10 ${ 165 - canLink ? "hover:bg-mediaCard-hoverBackground tabbable" : "" 166 - } ${closable ? "jiggle" : ""}`} 167 - tabIndex={canLink ? 0 : -1} 168 - onKeyUp={(e) => e.key === "Enter" && e.currentTarget.click()} 169 - > 170 - <Flare.Light 171 - flareSize={300} 172 - cssColorVar="--colors-mediaCard-hoverAccent" 173 - backgroundClass="bg-mediaCard-hoverBackground duration-100" 174 - className={classNames({ 175 - "rounded-xl bg-background-main group-hover:opacity-100": canLink, 176 - })} 177 - /> 178 - <Flare.Child 179 - className={`pointer-events-auto relative mb-2 p-[0.4em] transition-transform duration-300 ${ 180 - canLink ? "group-hover:scale-95" : "opacity-60" 181 - }`} 159 + <div ref={targetRef as React.RefObject<HTMLDivElement>}> 160 + <Flare.Base 161 + className={`group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300 focus:relative focus:z-10 ${ 162 + canLink ? "hover:bg-mediaCard-hoverBackground tabbable" : "" 163 + } ${closable ? "jiggle" : ""}`} 164 + tabIndex={canLink ? 0 : -1} 165 + onKeyUp={(e) => e.key === "Enter" && e.currentTarget.click()} 182 166 > 183 - <div 184 - className={classNames( 185 - "relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-300", 186 - { 187 - "group-hover:rounded-lg": canLink, 188 - }, 189 - )} 190 - style={{ 191 - backgroundImage: media.poster 192 - ? `url(${media.poster})` 193 - : "url(/placeholder.png)", 194 - }} 167 + <Flare.Light 168 + flareSize={300} 169 + cssColorVar="--colors-mediaCard-hoverAccent" 170 + backgroundClass="bg-mediaCard-hoverBackground duration-100" 171 + className={classNames({ 172 + "rounded-xl bg-background-main group-hover:opacity-100": canLink, 173 + })} 174 + /> 175 + <Flare.Child 176 + className={`pointer-events-auto relative mb-2 p-[0.4em] transition-transform duration-300 ${ 177 + canLink ? "group-hover:scale-95" : "opacity-60" 178 + }`} 195 179 > 196 - {series ? ( 197 - <div 198 - className={[ 199 - "absolute right-2 top-2 rounded-md bg-mediaCard-badge px-2 py-1 transition-colors", 200 - ].join(" ")} 201 - > 202 - <p 180 + <div 181 + className={classNames( 182 + "relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-300", 183 + { 184 + "group-hover:rounded-lg": canLink, 185 + }, 186 + )} 187 + style={{ 188 + backgroundImage: isIntersecting 189 + ? media.poster 190 + ? `url(${media.poster})` 191 + : "url(/placeholder.png)" 192 + : "", 193 + }} 194 + > 195 + {series ? ( 196 + <div 203 197 className={[ 204 - "text-center text-xs font-bold text-mediaCard-badgeText transition-colors", 205 - closable ? "" : "group-hover:text-white", 198 + "absolute right-2 top-2 rounded-md bg-mediaCard-badge px-2 py-1 transition-colors", 206 199 ].join(" ")} 207 200 > 208 - {t("media.episodeDisplay", { 209 - season: series.season || 1, 210 - episode: series.episode, 211 - })} 212 - </p> 213 - </div> 214 - ) : null} 201 + <p 202 + className={[ 203 + "text-center text-xs font-bold text-mediaCard-badgeText transition-colors", 204 + closable ? "" : "group-hover:text-white", 205 + ].join(" ")} 206 + > 207 + {t("media.episodeDisplay", { 208 + season: series.season || 1, 209 + episode: series.episode, 210 + })} 211 + </p> 212 + </div> 213 + ) : null} 215 214 216 - {percentage !== undefined ? ( 217 - <> 218 - <div 219 - className={`absolute inset-x-0 -bottom-px pb-1 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${ 220 - canLink ? "group-hover:from-mediaCard-hoverShadow" : "" 221 - }`} 222 - /> 223 - <div 224 - className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${ 225 - canLink ? "group-hover:from-mediaCard-hoverShadow" : "" 226 - }`} 227 - /> 228 - <div className="absolute inset-x-0 bottom-0 p-3"> 229 - <div className="relative h-1 overflow-hidden rounded-full bg-mediaCard-barColor"> 230 - <div 231 - className="absolute inset-y-0 left-0 rounded-full bg-mediaCard-barFillColor" 232 - style={{ 233 - width: percentageString, 234 - }} 235 - /> 215 + {percentage !== undefined ? ( 216 + <> 217 + <div 218 + className={`absolute inset-x-0 -bottom-px pb-1 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${ 219 + canLink ? "group-hover:from-mediaCard-hoverShadow" : "" 220 + }`} 221 + /> 222 + <div 223 + className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${ 224 + canLink ? "group-hover:from-mediaCard-hoverShadow" : "" 225 + }`} 226 + /> 227 + <div className="absolute inset-x-0 bottom-0 p-3"> 228 + <div className="relative h-1 overflow-hidden rounded-full bg-mediaCard-barColor"> 229 + <div 230 + className="absolute inset-y-0 left-0 rounded-full bg-mediaCard-barFillColor" 231 + style={{ 232 + width: percentageString, 233 + }} 234 + /> 235 + </div> 236 236 </div> 237 + </> 238 + ) : null} 239 + 240 + {!closable && ( 241 + <div 242 + className="absolute bookmark-button" 243 + onClick={(e) => e.preventDefault()} 244 + > 245 + <MediaBookmarkButton media={media} /> 237 246 </div> 238 - </> 239 - ) : null} 247 + )} 248 + 249 + {searchQuery.length > 0 && !closable ? ( 250 + <div className="absolute" onClick={(e) => e.preventDefault()}> 251 + <MediaBookmarkButton media={media} /> 252 + </div> 253 + ) : null} 240 254 241 - {!closable && ( 242 255 <div 243 - className="absolute bookmark-button" 244 - onClick={(e) => e.preventDefault()} 256 + className={`absolute inset-0 flex items-center justify-center bg-mediaCard-badge bg-opacity-80 transition-opacity duration-500 ${ 257 + closable ? "opacity-100" : "pointer-events-none opacity-0" 258 + }`} 245 259 > 246 - <MediaBookmarkButton media={media} /> 260 + <IconPatch 261 + clickable 262 + className="text-2xl text-mediaCard-badgeText transition-transform hover:scale-110 duration-500" 263 + onClick={() => closable && onClose?.()} 264 + icon={Icons.X} 265 + /> 247 266 </div> 248 - )} 249 - 250 - {searchQuery.length > 0 && !closable ? ( 251 - <div className="absolute" onClick={(e) => e.preventDefault()}> 252 - <MediaBookmarkButton media={media} /> 253 - </div> 254 - ) : null} 255 - 256 - <div 257 - className={`absolute inset-0 flex items-center justify-center bg-mediaCard-badge bg-opacity-80 transition-opacity duration-500 ${ 258 - closable ? "opacity-100" : "pointer-events-none opacity-0" 259 - }`} 260 - > 261 - <IconPatch 262 - clickable 263 - className="text-2xl text-mediaCard-badgeText transition-transform hover:scale-110 duration-500" 264 - onClick={() => closable && onClose?.()} 265 - icon={Icons.X} 266 - /> 267 267 </div> 268 - </div> 269 - 270 - <h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white"> 271 - <span>{media.title}</span> 272 - </h1> 273 - <div className="media-info-container justify-content-center flex flex-wrap"> 274 - <DotList className="text-xs" content={dotListContent} /> 275 - </div> 276 268 277 - {!closable && ( 278 - <div className="absolute bottom-0 translate-y-1 right-1"> 279 - <button 280 - className="media-more-button p-2" 281 - type="button" 282 - onClick={(e) => { 283 - e.preventDefault(); 284 - e.stopPropagation(); 285 - onShowDetails?.(media); 286 - }} 287 - > 288 - <Icon 289 - className="text-xs font-semibold text-type-secondary" 290 - icon={Icons.ELLIPSIS} 291 - /> 292 - </button> 269 + <h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white"> 270 + <span>{media.title}</span> 271 + </h1> 272 + <div className="media-info-container justify-content-center flex flex-wrap"> 273 + <DotList className="text-xs" content={dotListContent} /> 293 274 </div> 294 - )} 295 - {editable && closable && ( 296 - <div className="absolute bottom-0 translate-y-1 right-1"> 297 - <button 298 - className="media-more-button p-2" 299 - type="button" 300 - onClick={(e) => { 301 - e.preventDefault(); 302 - e.stopPropagation(); 303 - onEdit?.(); 304 - }} 305 - > 306 - <Icon 307 - className="text-xs font-semibold text-type-secondary" 308 - icon={Icons.EDIT} 309 - /> 310 - </button> 311 - </div> 312 - )} 313 - </Flare.Child> 314 - </Flare.Base> 275 + 276 + {!closable && ( 277 + <div className="absolute bottom-0 translate-y-1 right-1"> 278 + <button 279 + className="media-more-button p-2" 280 + type="button" 281 + onClick={(e) => { 282 + e.preventDefault(); 283 + e.stopPropagation(); 284 + onShowDetails?.(media); 285 + }} 286 + > 287 + <Icon 288 + className="text-xs font-semibold text-type-secondary" 289 + icon={Icons.ELLIPSIS} 290 + /> 291 + </button> 292 + </div> 293 + )} 294 + {editable && closable && ( 295 + <div className="absolute bottom-0 translate-y-1 right-1"> 296 + <button 297 + className="media-more-button p-2" 298 + type="button" 299 + onClick={(e) => { 300 + e.preventDefault(); 301 + e.stopPropagation(); 302 + onEdit?.(); 303 + }} 304 + > 305 + <Icon 306 + className="text-xs font-semibold text-type-secondary" 307 + icon={Icons.EDIT} 308 + /> 309 + </button> 310 + </div> 311 + )} 312 + </Flare.Child> 313 + </Flare.Base> 314 + </div> 315 315 ); 316 316 } 317 317
+2 -50
src/pages/discover/components/MediaCarousel.tsx
··· 15 15 useDiscoverMedia, 16 16 useDiscoverOptions, 17 17 } from "@/pages/discover/hooks/useDiscoverMedia"; 18 - import { useIntersectionObserver } from "@/pages/discover/hooks/useIntersectionObserver"; 19 18 import { useDiscoverStore } from "@/stores/discover"; 20 19 import { useProgressStore } from "@/stores/progress"; 21 20 import { MediaItem } from "@/utils/mediaTypes"; ··· 114 113 title: item.title || "", 115 114 })); 116 115 117 - // Set up intersection observer for lazy loading 118 - const { targetRef, isIntersecting, hasIntersected } = useIntersectionObserver( 119 - { 120 - rootMargin: "300px", 121 - }, 122 - ); 123 - 124 116 // Handle provider/genre selection 125 117 const handleProviderChange = React.useCallback((id: string, name: string) => { 126 118 setSelectedProviderId(id); ··· 197 189 content.type, 198 190 ]); 199 191 200 - // Fetch media using our hook - only when carousel has been visible 192 + // Fetch media using our hook 201 193 const { media, sectionTitle, actualContentType } = useDiscoverMedia({ 202 194 contentType, 203 195 mediaType, ··· 207 199 providerName: selectedProviderName, 208 200 mediaTitle: selectedRecommendationTitle, 209 201 isCarouselView: true, 210 - enabled: hasIntersected, 211 202 }); 212 203 213 204 // Find active button ··· 311 302 actualContentType, 312 303 ]); 313 304 314 - // Loading state 315 - if (!isIntersecting || !sectionTitle) { 316 - return ( 317 - <div ref={targetRef as React.RefObject<HTMLDivElement>}> 318 - <div className="flex items-center justify-between ml-2 md:ml-8 mt-2"> 319 - <div className="flex gap-4 items-center"> 320 - <h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-5 text-balance"> 321 - {t("discover.carousel.title.loading")} 322 - </h2> 323 - </div> 324 - </div> 325 - <div className="relative overflow-hidden carousel-container md:pb-4"> 326 - <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"> 327 - <div className="md:w-12" /> 328 - {Array(10) 329 - .fill(null) 330 - .map((_, index) => ( 331 - <div 332 - key={`skeleton-loading-${Math.random().toString(36).substring(2)}`} 333 - 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" 334 - > 335 - <MediaCard 336 - media={{ 337 - id: `skeleton-${index}`, 338 - title: "", 339 - poster: "", 340 - type: isTVShow ? "show" : "movie", 341 - }} 342 - forceSkeleton 343 - /> 344 - </div> 345 - ))} 346 - <div className="md:w-12" /> 347 - </div> 348 - </div> 349 - </div> 350 - ); 351 - } 352 - 353 305 return ( 354 - <div ref={targetRef as React.RefObject<HTMLDivElement>}> 306 + <div> 355 307 <div className="flex items-center justify-between ml-2 md:ml-8 mt-2"> 356 308 <div className="flex flex-col"> 357 309 <div className="flex items-center gap-4">