[Jellyseerr] Add cast/crew results

implements #327
This commit is contained in:
herrrta
2025-01-05 02:53:41 -05:00
parent b506871c46
commit db4046267f
12 changed files with 354 additions and 27 deletions

View File

@@ -0,0 +1,206 @@
import {router, useLocalSearchParams, useNavigation, useSegments} from "expo-router";
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {TouchableOpacity, View} from "react-native";
import {useQuery} from "@tanstack/react-query";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {useSafeAreaInsets} from "react-native-safe-area-context";
import {ParallaxScrollView} from "@/components/ParallaxPage";
import {Text} from "@/components/common/Text";
import {Animated} from "react-native";
import {Image} from "expo-image";
import {OverviewText} from "@/components/OverviewText";
import {orderBy} from "lodash";
import {FlashList} from "@shopify/flash-list";
import {PersonCreditCast} from "@/utils/jellyseerr/server/models/Person";
import Poster from "@/components/posters/Poster";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
const ANIMATION_ENTER = 250
const ANIMATION_EXIT = 250
const BACKDROP_DURATION = 5000
export default function page() {
const insets = useSafeAreaInsets();
const local = useLocalSearchParams();
const segments = useSegments();
const {jellyseerrApi, jellyseerrUser} = useJellyseerr();
const { personId } = local as { personId: string; };
const from = segments[2];
const [currentIndex, setCurrentIndex] = useState(0);
const fadeAnim = useRef(new Animated.Value(0)).current;
const {data, isLoading, isFetching} = useQuery({
queryKey: ['jellyseerr', 'person', personId],
queryFn: async () => ({
details: await jellyseerrApi?.personDetails(personId),
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId)
}),
enabled: !!jellyseerrApi && !!personId
});
const locale = useMemo(() => {
return jellyseerrUser?.settings?.locale || 'en'
}, [jellyseerrUser]);
const region = useMemo(
() => jellyseerrUser?.settings?.region || 'US',
[jellyseerrUser]
);
const castedRoles: PersonCreditCast[] = useMemo(
() => orderBy(data?.combinedCredits?.cast, ['voteCount', 'voteAverage'], 'desc'),
[data?.combinedCredits]
);
const backdrops = useMemo(
() => castedRoles.map(c => c.backdropPath),
[data?.combinedCredits]
)
const enterAnimation = useCallback(() =>
Animated.timing(fadeAnim, {
toValue: 1,
duration: ANIMATION_ENTER,
useNativeDriver: true,
}), [fadeAnim]);
const exitAnimation = useCallback(() =>
Animated.timing(fadeAnim, {
toValue: 0,
duration: ANIMATION_EXIT,
useNativeDriver: true,
}), [fadeAnim]);
useEffect(() => {
if (backdrops?.length) {
enterAnimation().start()
const intervalId = setInterval(
() => {
exitAnimation().start(end => {
if (end.finished)
setCurrentIndex((prevIndex) => (prevIndex + 1) % backdrops?.length)
})
},
BACKDROP_DURATION
);
return () => clearInterval(intervalId);
}
}, [backdrops, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
const viewDetails = (credit: PersonCreditCast) => {
router.push({
//@ts-ignore
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
//@ts-ignore
params: {
...credit,
mediaTitle: credit.title,
releaseYear: new Date(credit.releaseDate).getFullYear(),
canRequest: "false",
posterSrc: jellyseerrApi?.imageProxy(credit.posterPath, 'w300_and_h450_face')
}
});
}
return (
<View
className="flex-1 relative"
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<ParallaxScrollView
className="flex-1 opacity-100"
headerHeight={300}
headerImage={
<Animated.Image
source={{
uri: jellyseerrApi?.imageProxy(backdrops?.[currentIndex], 'w1920_and_h800_multi_faces'),
}}
style={{
width: "100%",
height: "100%",
opacity: fadeAnim
}}
/>
}
logo={
<Image
key={data?.details?.id}
id={data?.details?.id.toString()}
className="rounded-full bottom-1"
source={{uri: jellyseerrApi?.imageProxy(data?.details?.profilePath, 'w600_and_h600_bestv2'),}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
width: 125,
height: 125,
}}
/>
}
>
<View className="flex flex-col space-y-4 px-4">
<View className="flex flex-row justify-between w-full">
<View className="flex flex-col w-full">
<Text className="font-bold text-2xl mb-1">
{data?.details?.name}
</Text>
<Text className="opacity-50">
Born {new Date(data?.details?.birthday!!).toLocaleDateString(`${locale}-${region}`, {
year: 'numeric',
month: 'long',
day: 'numeric',
})} | {data?.details?.placeOfBirth}
</Text>
</View>
</View>
<OverviewText text={data?.details?.biography} className="mt-4"/>
<View>
<FlashList
data={castedRoles}
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
</View>
}
contentInsetAdjustmentBehavior="automatic"
ListHeaderComponent={
<Text className="text-lg font-bold my-2">Appearances</Text>
}
renderItem={({item}) =>
<TouchableOpacity
className="w-full flex flex-col"
onPress={() => viewDetails(item)}
>
<Poster
id={item.id.toString()}
url={jellyseerrApi?.imageProxy(item.posterPath)}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
mediaType={(item.mediaType as "movie" | "tv")}
/>
{/*<Text numberOfLines={1}>{item.title}</Text>*/}
<Text className="text-xs opacity-50 align-bottom" numberOfLines={1}>as {item.character}</Text>
</TouchableOpacity>
}
keyExtractor={(item) => item.id.toString()}
estimatedItemSize={255}
numColumns={3}
contentContainerStyle={{paddingBottom: 24}}
ItemSeparatorComponent={() => (
<View className="h-2 w-2"/>
)}
/>
</View>
</View>
</ParallaxScrollView>
</View>
);
}

View File

@@ -30,6 +30,7 @@ import { JellyserrRatings } from "@/components/Ratings";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import {ItemActions} from "@/components/series/SeriesActions";
import Cast from "@/components/jellyseerr/Cast";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
@@ -156,7 +157,7 @@ const Page: React.FC = () => {
height: "100%",
}}
source={{
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
uri: jellyseerrApi?.imageProxy(result.backdropPath, 'w1920_and_h800_multi_faces'),
}}
/>
) : (
@@ -240,6 +241,10 @@ const Page: React.FC = () => {
className="p-2 border border-neutral-800 bg-neutral-900 rounded-xl"
details={details}
/>
<Cast
className="px-4"
details={details}
/>
</View>
</View>
</ParallaxScrollView>

View File

@@ -36,6 +36,7 @@ export default function SearchLayout() {
}}
/>
<Stack.Screen name="jellyseerr/page" options={commonScreenOptions} />
<Stack.Screen name="jellyseerr/[personId]" options={commonScreenOptions} />
</Stack>
);
}

View File

@@ -31,12 +31,13 @@ import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import {MovieResult, PersonResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Tag } from "@/components/GenreTags";
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
import { sortBy } from "lodash";
import PersonPoster from "@/components/jellyseerr/PersonPoster";
type SearchType = "Library" | "Discover";
@@ -191,6 +192,14 @@ export default function search() {
[jellyseerrResults]
);
const jellyseerrPersonResults: PersonResult[] | undefined = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === "person"
) as PersonResult[],
[jellyseerrResults]
);
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
@@ -486,6 +495,19 @@ export default function search() {
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header="Actors"
items={jellyseerrPersonResults}
renderItem={(item: PersonResult) => (
<PersonPoster
className="mr-2"
key={item.id}
id={item.id.toString()}
name={item.name}
posterPath={item.profilePath}
/>
)}
/>
</>
)}

View File

@@ -0,0 +1,34 @@
import {View, ViewProps} from "react-native";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import React from "react";
import {FlashList} from "@shopify/flash-list";
import {Text} from "@/components/common/Text";
import PersonPoster from "@/components/jellyseerr/PersonPoster";
const CastSlide: React.FC<{ details?: MovieDetails | TvDetails } & ViewProps> = ({ details, ...props }) => {
return (
details?.credits?.cast?.length && details?.credits?.cast?.length > 0 &&
<View {...props}>
<Text className="text-lg font-bold mb-2">Cast</Text>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={details?.credits.cast}
ItemSeparatorComponent={() => <View className="w-2"/>}
estimatedItemSize={15}
keyExtractor={item => item?.id?.toString()}
renderItem={({item}) =>
<PersonPoster
id={item.id.toString()}
posterPath={item.profilePath}
name={item.name}
subName={item.character}
/>
}
/>
</View>
)
}
export default CastSlide;

View File

@@ -0,0 +1,42 @@
import {TouchableOpacity, View, ViewProps} from "react-native";
import React from "react";
import {Text} from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import {useRouter, useSegments} from "expo-router";
import {useJellyseerr} from "@/hooks/useJellyseerr";
interface Props {
id: string
posterPath?: string
name: string
subName?: string
}
const PersonPoster: React.FC<Props & ViewProps> = ({
id,
posterPath,
name,
subName,
...props
}) => {
const {jellyseerrApi} = useJellyseerr();
const router = useRouter();
const segments = useSegments();
const from = segments[2];
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
<TouchableOpacity onPress={() => router.push(`/(auth)/(tabs)/${from}/jellyseerr/${id}`)}>
<View className="flex flex-col w-28" {...props}>
<Poster
id={id}
url={jellyseerrApi?.imageProxy(posterPath, 'w600_and_h900_bestv2')}
/>
<Text className="mt-2">{name}</Text>
{subName && <Text className="text-xs opacity-50">{subName}</Text>}
</View>
</TouchableOpacity>
)
}
export default PersonPoster;

View File

@@ -20,10 +20,8 @@ const JellyseerrPoster: React.FC<Props> = ({
const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
// const imageSource =
const imageSrc = useMemo(() =>
item.posterPath ?
`https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
: jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
const imageSrc = useMemo(
() => jellyseerrApi?.imageProxy(item.posterPath, 'w300_and_h450_face'),
[item, jellyseerrApi]
)
const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
@@ -57,7 +55,7 @@ const JellyseerrPoster: React.FC<Props> = ({
mediaTitle={title}
releaseYear={releaseYear}
canRequest={canRequest}
posterSrc={imageSrc}
posterSrc={imageSrc!!}
>
<View className="flex flex-col w-28 mr-2">
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">

View File

@@ -1,19 +1,15 @@
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { View } from "react-native";
type PosterProps = {
item?: BaseItemDto | BaseItemPerson | null;
id?: string | null;
url?: string | null;
showProgress?: boolean;
blurhash?: string | null;
};
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
if (!item)
const Poster: React.FC<PosterProps> = ({ id, url, blurhash }) => {
if (!id && !url)
return (
<View
className="border border-neutral-900"
@@ -33,8 +29,8 @@ const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
}
: null
}
key={item.Id}
id={item.Id}
key={id}
id={id!!}
source={
url
? {

View File

@@ -55,7 +55,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
}}
className="flex flex-col w-28"
>
<Poster item={i} url={getPrimaryImageUrl({ api, item: i })} />
<Poster id={i.id} url={getPrimaryImageUrl({ api, item: i })} />
<Text className="mt-2">{i.Name}</Text>
<Text className="text-xs opacity-50">{i.Role}</Text>
</TouchableOpacity>

View File

@@ -29,7 +29,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
className="flex flex-col space-y-2 w-28"
>
<Poster
item={item}
id={item.id}
url={getPrimaryImageUrlById({ api, id: item.ParentId })}
/>
<Text>{item.SeriesName}</Text>

View File

@@ -61,7 +61,7 @@ const RenderItem = ({ item, index }: any) => {
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
uri: jellyseerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"

View File

@@ -28,6 +28,10 @@ import Issue from "@/utils/jellyseerr/server/entity/Issue";
import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
import { writeErrorLog } from "@/utils/log";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import {
CombinedCredit,
PersonDetails
} from "@/utils/jellyseerr/server/models/Person";
interface SearchParams {
query: string;
@@ -55,6 +59,8 @@ export enum Endpoints {
API_V1 = "/api/v1",
SEARCH = "/search",
REQUEST = "/request",
PERSON = "/person",
COMBINED_CREDITS = "/combined_credits",
MOVIE = "/movie",
RATINGS = "/ratings",
ISSUE = "/issue",
@@ -204,6 +210,22 @@ export class JellyseerrApi {
});
}
async personDetails(id: number | string): Promise<PersonDetails> {
return this.axios
?.get<PersonDetails>(Endpoints.API_V1 + Endpoints.PERSON + `/${id}`)
.then((response) => {
return response?.data;
});
}
async personCombinedCredits(id: number | string): Promise<CombinedCredit> {
return this.axios
?.get<CombinedCredit>(Endpoints.API_V1 + Endpoints.PERSON + `/${id}` + Endpoints.COMBINED_CREDITS)
.then((response) => {
return response?.data;
});
}
async movieRatings(id: number) {
return this.axios
?.get<RTRating>(
@@ -238,14 +260,15 @@ export class JellyseerrApi {
});
}
tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) {
return (
this.axios.defaults.baseURL +
`/_next/image?` +
new URLSearchParams(
`url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`
).toString()
);
imageProxy(path?: string, tmdbPath: string = 'original', width: number = 1920, quality: number = 75) {
return path ? (
this.axios.defaults.baseURL +
`/_next/image?` +
new URLSearchParams(
`url=https://image.tmdb.org/t/p/${tmdbPath}/${path}&w=${width}&q=${quality}`
).toString()
) :
this.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`;
}
async submitIssue(mediaId: number, issueType: IssueType, message: string) {