mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-29 05:38:52 +00:00
add people carousel
This commit is contained in:
parent
ac344e8ce9
commit
2011e1c2d5
5 changed files with 184 additions and 12 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
87
src/components/overlays/details/PeopleCarousel.tsx
Normal file
87
src/components/overlays/details/PeopleCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue