From db4046267ff72d69a34d5b6cf36a4b50b354a2ca Mon Sep 17 00:00:00 2001 From: herrrta <73949927+herrrta@users.noreply.github.com> Date: Sun, 5 Jan 2025 02:53:41 -0500 Subject: [PATCH] [Jellyseerr] Add cast/crew results implements #327 --- .../jellyseerr/[personId].tsx | 206 ++++++++++++++++++ .../jellyseerr/page.tsx | 7 +- app/(auth)/(tabs)/(search)/_layout.tsx | 1 + app/(auth)/(tabs)/(search)/index.tsx | 24 +- components/jellyseerr/Cast.tsx | 34 +++ components/jellyseerr/PersonPoster.tsx | 42 ++++ components/posters/JellyseerrPoster.tsx | 8 +- components/posters/Poster.tsx | 14 +- components/series/CastAndCrew.tsx | 2 +- components/series/CurrentSeries.tsx | 2 +- components/series/JellyseerrSeasons.tsx | 2 +- hooks/useJellyseerr.ts | 39 +++- 12 files changed, 354 insertions(+), 27 deletions(-) create mode 100644 app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx create mode 100644 components/jellyseerr/Cast.tsx create mode 100644 components/jellyseerr/PersonPoster.tsx diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx new file mode 100644 index 00000000..f2219b26 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx @@ -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 ( + + + } + logo={ + + } + > + + + + + {data?.details?.name} + + + Born {new Date(data?.details?.birthday!!).toLocaleDateString(`${locale}-${region}`, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} | {data?.details?.placeOfBirth} + + + + + + + + No results + + } + contentInsetAdjustmentBehavior="automatic" + ListHeaderComponent={ + Appearances + } + renderItem={({item}) => + viewDetails(item)} + > + + + {/*{item.title}*/} + as {item.character} + + } + keyExtractor={(item) => item.id.toString()} + estimatedItemSize={255} + numColumns={3} + contentContainerStyle={{paddingBottom: 24}} + ItemSeparatorComponent={() => ( + + )} + /> + + + + + ); +} \ No newline at end of file diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx index 31334dcc..805727fd 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx @@ -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} /> + diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx index 12cbad20..1119e2a4 100644 --- a/app/(auth)/(tabs)/(search)/_layout.tsx +++ b/app/(auth)/(tabs)/(search)/_layout.tsx @@ -36,6 +36,7 @@ export default function SearchLayout() { }} /> + ); } diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx index 7d9ecebe..fcf8119d 100644 --- a/app/(auth)/(tabs)/(search)/index.tsx +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -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() { )} /> + ( + + )} + /> )} diff --git a/components/jellyseerr/Cast.tsx b/components/jellyseerr/Cast.tsx new file mode 100644 index 00000000..7642382d --- /dev/null +++ b/components/jellyseerr/Cast.tsx @@ -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 && + + Cast + } + estimatedItemSize={15} + keyExtractor={item => item?.id?.toString()} + renderItem={({item}) => + + } + /> + + ) +} + +export default CastSlide; \ No newline at end of file diff --git a/components/jellyseerr/PersonPoster.tsx b/components/jellyseerr/PersonPoster.tsx new file mode 100644 index 00000000..57ff9f58 --- /dev/null +++ b/components/jellyseerr/PersonPoster.tsx @@ -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 = ({ + 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 ( + router.push(`/(auth)/(tabs)/${from}/jellyseerr/${id}`)}> + + + {name} + {subName && {subName}} + + + ) +} + +export default PersonPoster; \ No newline at end of file diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx index 0de80a75..ad3df50f 100644 --- a/components/posters/JellyseerrPoster.tsx +++ b/components/posters/JellyseerrPoster.tsx @@ -20,10 +20,8 @@ const JellyseerrPoster: React.FC = ({ 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 = ({ mediaTitle={title} releaseYear={releaseYear} canRequest={canRequest} - posterSrc={imageSrc} + posterSrc={imageSrc!!} > diff --git a/components/posters/Poster.tsx b/components/posters/Poster.tsx index 1787506e..68799f47 100644 --- a/components/posters/Poster.tsx +++ b/components/posters/Poster.tsx @@ -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 = ({ item, url, blurhash }) => { - if (!item) +const Poster: React.FC = ({ id, url, blurhash }) => { + if (!id && !url) return ( = ({ item, url, blurhash }) => { } : null } - key={item.Id} - id={item.Id} + key={id} + id={id!!} source={ url ? { diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx index 2b312f0e..01cd1e84 100644 --- a/components/series/CastAndCrew.tsx +++ b/components/series/CastAndCrew.tsx @@ -55,7 +55,7 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => { }} className="flex flex-col w-28" > - + {i.Name} {i.Role} diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx index e573929a..52851533 100644 --- a/components/series/CurrentSeries.tsx +++ b/components/series/CurrentSeries.tsx @@ -29,7 +29,7 @@ export const CurrentSeries: React.FC = ({ item, ...props }) => { className="flex flex-col space-y-2 w-28" > {item.SeriesName} diff --git a/components/series/JellyseerrSeasons.tsx b/components/series/JellyseerrSeasons.tsx index d6dedeb6..80bfd254 100644 --- a/components/series/JellyseerrSeasons.tsx +++ b/components/series/JellyseerrSeasons.tsx @@ -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" diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index 8393798d..4546cee1 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -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 { + return this.axios + ?.get(Endpoints.API_V1 + Endpoints.PERSON + `/${id}`) + .then((response) => { + return response?.data; + }); + } + + async personCombinedCredits(id: number | string): Promise { + return this.axios + ?.get(Endpoints.API_V1 + Endpoints.PERSON + `/${id}` + Endpoints.COMBINED_CREDITS) + .then((response) => { + return response?.data; + }); + } + async movieRatings(id: number) { return this.axios ?.get( @@ -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) {