add people carousel

This commit is contained in:
Pas 2025-06-02 00:22:25 -06:00
parent ac344e8ce9
commit 2011e1c2d5
5 changed files with 184 additions and 12 deletions

View file

@ -11,10 +11,13 @@ import { MWMediaMeta, MWMediaType, MWSeasonMeta } from "./types/mw";
import {
ExternalIdMovieSearchResult,
TMDBContentTypes,
TMDBCredits,
TMDBEpisodeShort,
TMDBMediaResult,
TMDBMovieData,
TMDBMovieSearchResult,
TMDBPerson,
TMDBPersonImages,
TMDBSearchResult,
TMDBSeason,
TMDBSeasonMetaResult,
@ -424,3 +427,36 @@ export async function getMediaLogo(
return undefined;
}
}
export async function getMediaCredits(
id: string,
type: TMDBContentTypes,
): Promise<TMDBCredits> {
const endpoint = type === TMDBContentTypes.MOVIE ? "movie" : "tv";
return get<TMDBCredits>(`/${endpoint}/${id}/credits`);
}
export async function getPersonDetails(id: string): Promise<TMDBPerson> {
return get<TMDBPerson>(`/person/${id}`);
}
export async function getPersonImages(id: string): Promise<TMDBPersonImages> {
return get<TMDBPersonImages>(`/person/${id}/images`);
}
export function getPersonProfileImage(
profilePath: string | null,
): string | undefined {
const shouldProxyTmdb = usePreferencesStore.getState().proxyTmdb;
const imgUrl = `https://image.tmdb.org/t/p/w185/${profilePath}`;
if (shouldProxyTmdb) {
const proxyUrls = getProxyUrls();
const proxy = getNextProxy(proxyUrls);
if (proxy) {
return `${proxy}/?destination=${imgUrl}`;
}
}
if (profilePath) return imgUrl;
}

View file

@ -334,3 +334,50 @@ export interface TMDBSearchResult {
total_pages: number;
total_results: number;
}
export interface TMDBCastMember {
id: number;
name: string;
character: string;
profile_path: string | null;
order: number;
}
export interface TMDBCrewMember {
id: number;
name: string;
job: string;
department: string;
profile_path: string | null;
}
export interface TMDBCredits {
id: number;
cast: TMDBCastMember[];
crew: TMDBCrewMember[];
}
export interface TMDBPerson {
id: number;
name: string;
biography: string;
birthday: string | null;
deathday: string | null;
place_of_birth: string | null;
profile_path: string | null;
known_for_department: string;
}
export interface TMDBPersonImage {
file_path: string;
aspect_ratio: number;
height: number;
width: number;
vote_average: number;
vote_count: number;
}
export interface TMDBPersonImages {
id: number;
profiles: TMDBPersonImage[];
}

View file

@ -2,6 +2,7 @@ import { t } from "i18next";
import { useEffect, useMemo, useState } from "react";
import { useCopyToClipboard } from "react-use";
import { TMDBContentTypes } from "@/backend/metadata/types/tmdb";
import { Icon, Icons } from "@/components/Icon";
import { useLanguageStore } from "@/stores/language";
import { useProgressStore } from "@/stores/progress";
@ -13,6 +14,7 @@ import { scrapeRottenTomatoes } from "@/utils/rottenTomatoesScraper";
import { DetailsHeader } from "./DetailsHeader";
import { DetailsInfo } from "./DetailsInfo";
import { EpisodeCarousel } from "./EpisodeCarousel";
import { CastCarousel } from "./PeopleCarousel";
import { TrailerOverlay } from "./TrailerOverlay";
import { DetailsContentProps } from "./types";
@ -205,6 +207,18 @@ export function DetailsContent({ data, minimal = false }: DetailsContentProps) {
mediaTitle={data.title}
/>
)}
{/* Cast Carousel */}
{data.id && (
<CastCarousel
mediaId={data.id.toString()}
mediaType={
data.type === "movie"
? TMDBContentTypes.MOVIE
: TMDBContentTypes.TV
}
/>
)}
</div>
</div>
);

View file

@ -53,12 +53,6 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
rating: movieDetails.release_dates?.results?.find(
(r) => r.iso_3166_1 === "US",
)?.release_dates?.[0]?.certification,
director: movieDetails.credits?.crew?.find(
(person) => person.job === "Director",
)?.name,
actors: movieDetails.credits?.cast
?.slice(0, 5)
.map((actor) => actor.name),
type: "movie",
id: movieDetails.id,
imdbId: movieDetails.external_ids?.imdb_id,
@ -90,12 +84,6 @@ export function DetailsModal({ id, data, minimal }: DetailsModalProps) {
rating: showDetails.content_ratings?.results?.find(
(r) => r.iso_3166_1 === "US",
)?.rating,
director: showDetails.credits?.crew?.find(
(person) => person.job === "Director",
)?.name,
actors: showDetails.credits?.cast
?.slice(0, 5)
.map((actor) => actor.name),
type: "show",
id: showDetails.id,
imdbId: showDetails.external_ids?.imdb_id,

View file

@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
getMediaCredits,
getPersonProfileImage,
} from "@/backend/metadata/tmdb";
import {
TMDBCastMember,
TMDBContentTypes,
TMDBCrewMember,
} from "@/backend/metadata/types/tmdb";
interface CastCarouselProps {
mediaId: string;
mediaType: TMDBContentTypes;
}
export function CastCarousel({ mediaId, mediaType }: CastCarouselProps) {
const { t } = useTranslation();
const [cast, setCast] = useState<TMDBCastMember[]>([]);
const [director, setDirector] = useState<TMDBCrewMember | null>(null);
useEffect(() => {
async function loadCast() {
try {
const credits = await getMediaCredits(mediaId, mediaType);
// Find the director
const foundDirector = credits.crew.find(
(member) => member.job === "Director" && member.profile_path,
);
setDirector(foundDirector || null);
// Only show cast members with profile images
const castWithImages = credits.cast
.filter((member) => member.profile_path)
.slice(0, 20); // Limit to top 20 cast members
setCast(castWithImages);
} catch (err) {
console.error("Failed to load cast:", err);
}
}
loadCast();
}, [mediaId, mediaType]);
if (cast.length === 0 && !director) return null;
return (
<div className="space-y-4 pt-8">
<div className="flex overflow-x-auto scrollbar-none pb-4 gap-4">
{director && (
<div className="flex flex-col items-center space-y-2 flex-shrink-0">
<div className="relative h-32 w-32 overflow-hidden rounded-full">
<img
src={getPersonProfileImage(director.profile_path)}
alt={director.name}
className="h-full w-full object-cover"
/>
</div>
<div className="text-center w-32 flex flex-col">
<span className="font-medium truncate">{director.name}</span>
<span className="text-sm truncate">{t("Director")}</span>
</div>
</div>
)}
{cast.map((member) => (
<div
key={member.id}
className="flex flex-col items-center space-y-2 flex-shrink-0"
>
<div className="relative h-32 w-32 overflow-hidden rounded-full">
<img
src={getPersonProfileImage(member.profile_path)}
alt={member.name}
className="h-full w-full object-cover"
/>
</div>
<div className="text-center w-32 flex flex-col">
<span className="font-medium truncate">{member.name}</span>
<span className="text-sm truncate">{member.character}</span>
</div>
</div>
))}
</div>
</div>
);
}