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

add similar media carousel to details modal

Pas e7e49f81 6997acd7

+140
+1
src/assets/locales/en.json
··· 423 423 "airs": "Airs", 424 424 "endsAt": "Ends at {{time}}", 425 425 "trailer": "Trailer", 426 + "similar": "Similar", 426 427 "collection": { 427 428 "movies": "Movies", 428 429 "movie": "Movie",
+126
src/components/overlays/detailsModal/components/carousels/SimilarMediaCarousel.tsx
··· 1 + import { useEffect, useRef, useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 3 + 4 + import { getMediaPoster, getRelatedMedia } from "@/backend/metadata/tmdb"; 5 + import { TMDBContentTypes } from "@/backend/metadata/types/tmdb"; 6 + import { MediaCard } from "@/components/media/MediaCard"; 7 + import { useIsMobile } from "@/hooks/useIsMobile"; 8 + import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; 9 + import { useOverlayStack } from "@/stores/interface/overlayStack"; 10 + import { MediaItem } from "@/utils/mediaTypes"; 11 + 12 + interface SimilarMediaCarouselProps { 13 + mediaId: string; 14 + mediaType: TMDBContentTypes; 15 + } 16 + 17 + export function SimilarMediaCarousel({ 18 + mediaId, 19 + mediaType, 20 + }: SimilarMediaCarouselProps) { 21 + const { t } = useTranslation(); 22 + const { isMobile } = useIsMobile(); 23 + const { showModal } = useOverlayStack(); 24 + const [similarMedia, setSimilarMedia] = useState<MediaItem[]>([]); 25 + const carouselRef = useRef<HTMLDivElement>(null); 26 + const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({ 27 + similar: null, 28 + }); 29 + 30 + useEffect(() => { 31 + const loadSimilarMedia = async () => { 32 + try { 33 + const results = await getRelatedMedia(mediaId, mediaType, 12); 34 + const mediaItems: MediaItem[] = results.map((result) => { 35 + const isMovie = "title" in result; 36 + return { 37 + id: result.id.toString(), 38 + title: isMovie ? result.title : result.name, 39 + poster: getMediaPoster(result.poster_path) || "/placeholder.png", 40 + type: mediaType === TMDBContentTypes.MOVIE ? "movie" : "show", 41 + year: isMovie 42 + ? result.release_date 43 + ? new Date(result.release_date).getFullYear() 44 + : 0 45 + : result.first_air_date 46 + ? new Date(result.first_air_date).getFullYear() 47 + : 0, 48 + release_date: isMovie 49 + ? result.release_date 50 + ? new Date(result.release_date) 51 + : undefined 52 + : result.first_air_date 53 + ? new Date(result.first_air_date) 54 + : undefined, 55 + }; 56 + }); 57 + setSimilarMedia(mediaItems); 58 + } catch (err) { 59 + console.error("Failed to load similar media:", err); 60 + } 61 + }; 62 + 63 + loadSimilarMedia(); 64 + }, [mediaId, mediaType]); 65 + 66 + useEffect(() => { 67 + if (carouselRef.current) { 68 + carouselRefs.current.similar = carouselRef.current; 69 + } 70 + }, []); 71 + 72 + const handleShowDetails = (media: MediaItem) => { 73 + showModal("details", { 74 + id: Number(media.id), 75 + type: media.type === "movie" ? "movie" : "show", 76 + }); 77 + }; 78 + 79 + if (similarMedia.length === 0) return null; 80 + 81 + return ( 82 + <div className="space-y-4 pt-8"> 83 + <h3 className="text-lg font-semibold text-white/90"> 84 + {t("details.similar")} 85 + </h3> 86 + 87 + <div className="relative"> 88 + {/* Carousel Container */} 89 + <div 90 + ref={carouselRef} 91 + 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" 92 + style={{ 93 + scrollSnapType: "x mandatory", 94 + scrollBehavior: "smooth", 95 + }} 96 + > 97 + <div className="md:w-12" /> 98 + 99 + {similarMedia.map((media) => ( 100 + <div 101 + key={media.id} 102 + className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto" 103 + style={{ scrollSnapAlign: "start" }} 104 + > 105 + <MediaCard 106 + media={media} 107 + linkable 108 + onShowDetails={handleShowDetails} 109 + /> 110 + </div> 111 + ))} 112 + 113 + <div className="md:w-12" /> 114 + </div> 115 + 116 + {/* Navigation Buttons */} 117 + {!isMobile && ( 118 + <CarouselNavButtons 119 + categorySlug="similar" 120 + carouselRefs={carouselRefs} 121 + /> 122 + )} 123 + </div> 124 + </div> 125 + ); 126 + }
+13
src/components/overlays/detailsModal/components/layout/DetailsContent.tsx
··· 16 16 import { DetailsContentProps } from "../../types"; 17 17 import { EpisodeCarousel } from "../carousels/EpisodeCarousel"; 18 18 import { CastCarousel } from "../carousels/PeopleCarousel"; 19 + import { SimilarMediaCarousel } from "../carousels/SimilarMediaCarousel"; 19 20 import { TrailerCarousel } from "../carousels/TrailerCarousel"; 20 21 import { CollectionOverlay } from "../overlays/CollectionOverlay"; 21 22 import { TrailerOverlay } from "../overlays/TrailerOverlay"; ··· 386 387 trailer_url: trailerUrl, 387 388 })); 388 389 }} 390 + /> 391 + )} 392 + 393 + {/* Similar Media Carousel */} 394 + {data.id && ( 395 + <SimilarMediaCarousel 396 + mediaId={data.id.toString()} 397 + mediaType={ 398 + data.type === "movie" 399 + ? TMDBContentTypes.MOVIE 400 + : TMDBContentTypes.TV 401 + } 389 402 /> 390 403 )} 391 404 </div>