diff --git a/src/backend/metadata/tmdb.ts b/src/backend/metadata/tmdb.ts index 382b1d27..9ac15a7c 100644 --- a/src/backend/metadata/tmdb.ts +++ b/src/backend/metadata/tmdb.ts @@ -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 { + const endpoint = type === TMDBContentTypes.MOVIE ? "movie" : "tv"; + return get(`/${endpoint}/${id}/credits`); +} + +export async function getPersonDetails(id: string): Promise { + return get(`/person/${id}`); +} + +export async function getPersonImages(id: string): Promise { + return get(`/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; +} diff --git a/src/backend/metadata/types/tmdb.ts b/src/backend/metadata/types/tmdb.ts index 9af92c98..0367e9a7 100644 --- a/src/backend/metadata/types/tmdb.ts +++ b/src/backend/metadata/types/tmdb.ts @@ -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[]; +} diff --git a/src/components/overlays/details/DetailsContent.tsx b/src/components/overlays/details/DetailsContent.tsx index 0aade69d..26f2e8db 100644 --- a/src/components/overlays/details/DetailsContent.tsx +++ b/src/components/overlays/details/DetailsContent.tsx @@ -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 && ( + + )} ); diff --git a/src/components/overlays/details/DetailsModal.tsx b/src/components/overlays/details/DetailsModal.tsx index b4729f7b..d5691adf 100644 --- a/src/components/overlays/details/DetailsModal.tsx +++ b/src/components/overlays/details/DetailsModal.tsx @@ -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, diff --git a/src/components/overlays/details/PeopleCarousel.tsx b/src/components/overlays/details/PeopleCarousel.tsx new file mode 100644 index 00000000..6517348d --- /dev/null +++ b/src/components/overlays/details/PeopleCarousel.tsx @@ -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([]); + const [director, setDirector] = useState(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 ( +
+
+ {director && ( +
+
+ {director.name} +
+
+ {director.name} + {t("Director")} +
+
+ )} + {cast.map((member) => ( +
+
+ {member.name} +
+
+ {member.name} + {member.character} +
+
+ ))} +
+
+ ); +}