[Jellyseerr] Show genre/studio/network discover sliders

implements #326
This commit is contained in:
herrrta
2025-01-06 12:06:58 -05:00
parent 90ef8ef6f9
commit b232bebd73
18 changed files with 887 additions and 302 deletions

View File

@@ -1,247 +0,0 @@
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 pr-2"
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>*/}
{item.character && (
<Text
className="text-xs opacity-50 align-bottom mt-1"
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

@@ -0,0 +1,136 @@
import {router, useLocalSearchParams, useSegments,} from "expo-router";
import React, {useMemo,} from "react";
import {TouchableOpacity} from "react-native";
import {useInfiniteQuery} from "@tanstack/react-query";
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {Text} from "@/components/common/Text";
import {Image} from "expo-image";
import Poster from "@/components/posters/Poster";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {COMPANY_LOGO_IMAGE_FILTER} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import {uniqBy} from "lodash";
export default function page() {
const local = useLocalSearchParams();
const segments = useSegments();
const {jellyseerrApi} = useJellyseerr();
const from = segments[2];
const {companyId, name, image, type} = local as unknown as {
companyId: string,
name: string,
image: string,
type: DiscoverSliderType
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, companyId],
queryFn: async ({pageParam}) => {
let params: any = {
page: Number(pageParam),
};
return jellyseerrApi?.discover(
(
type == DiscoverSliderType.NETWORKS
? Endpoints.DISCOVER_TV_NETWORK
: Endpoints.DISCOVER_MOVIES_STUDIO
) + `/${companyId}`,
params
)
},
enabled: !!jellyseerrApi && !!companyId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
staleTime: 0,
});
const flatData = useMemo(
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
[data]
);
const backdrops = useMemo(
() => jellyseerrApi
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, flatData]
);
const viewDetails = (result: Results) => {
router.push({
//@ts-ignore
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
//@ts-ignore
params: {
...result,
mediaTitle: getName(result),
releaseYear: getYear(result),
canRequest: "false",
posterSrc: jellyseerrApi?.imageProxy(
(result as MovieResult | TvResult).posterPath,
"w300_and_h450_face"
),
},
});
};
const getName = (result: Results) => {
return (result as TvResult).name || (result as MovieResult).title
}
const getYear = (result: Results) => {
return new Date((result as TvResult).firstAirDate || (result as MovieResult).releaseDate).getFullYear()
}
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
logo={
<Image
id={companyId}
key={companyId}
className="bottom-1 w-1/2"
source={{
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
}}
cachePolicy={"memory-disk"}
contentFit="contain"
style={{
aspectRatio: "4/3",
}}
/>
}
renderItem={(item, index) => (
<TouchableOpacity
className="w-full flex flex-col pr-2"
onPress={() => viewDetails(item)}
>
<Poster
id={item.id.toString()}
url={jellyseerrApi?.imageProxy((item as MovieResult | TvResult).posterPath)}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
mediaType={item.mediaType as "movie" | "tv"}
/>
<Text className="mt-2" numberOfLines={1}>{getName(item)}</Text>
<Text className="text-xs opacity-50">{getYear(item)}</Text>
</TouchableOpacity>
)}
/>
);
}

View File

@@ -0,0 +1,128 @@
import {router, useLocalSearchParams, useSegments,} from "expo-router";
import React, {useMemo,} from "react";
import {TouchableOpacity} from "react-native";
import {useInfiniteQuery} from "@tanstack/react-query";
import {Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {Text} from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {uniqBy} from "lodash";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
export default function page() {
const local = useLocalSearchParams();
const segments = useSegments();
const {jellyseerrApi} = useJellyseerr();
const from = segments[2];
const {genreId, name, type} = local as unknown as {
genreId: string,
name: string,
type: DiscoverSliderType
};
const {data, fetchNextPage, hasNextPage} = useInfiniteQuery({
queryKey: ["jellyseerr", "company", type, genreId],
queryFn: async ({pageParam}) => {
let params: any = {
page: Number(pageParam),
genre: genreId
};
return jellyseerrApi?.discover(
type == DiscoverSliderType.MOVIE_GENRES
? Endpoints.DISCOVER_MOVIES
: Endpoints.DISCOVER_TV,
params
)
},
enabled: !!jellyseerrApi && !!genreId,
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
staleTime: 0,
});
const flatData = useMemo(
() => uniqBy(data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results ?? []), "id")?? [],
[data]
);
const backdrops = useMemo(
() => jellyseerrApi
? flatData.map((r) => jellyseerrApi.imageProxy((r as TvResult | MovieResult).backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, flatData]
);
const viewDetails = (result: Results) => {
router.push({
//@ts-ignore
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
//@ts-ignore
params: {
...result,
mediaTitle: getName(result),
releaseYear: getYear(result),
canRequest: "false",
posterSrc: jellyseerrApi?.imageProxy(
(result as MovieResult | TvResult).posterPath,
"w300_and_h450_face"
),
},
});
};
const getName = (result: Results) => {
return (result as TvResult).name || (result as MovieResult).title
}
const getYear = (result: Results) => {
return new Date((result as TvResult).firstAirDate || (result as MovieResult).releaseDate).getFullYear()
}
return (
<ParallaxSlideShow
data={flatData}
images={backdrops}
listHeader=""
keyExtractor={(item) => item.id.toString()}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
logo={
<Text
className="text-4xl font-bold text-center bottom-1"
style={{
...textShadowStyle.shadow,
shadowRadius: 10
}}>
{name}
</Text>
}
renderItem={(item, index) => (
<TouchableOpacity
className="w-full flex flex-col pr-2"
onPress={() => viewDetails(item)}
>
<Poster
id={item.id.toString()}
url={jellyseerrApi?.imageProxy((item as MovieResult | TvResult).posterPath)}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
mediaType={item.mediaType as "movie" | "tv"}
/>
<Text className="mt-2" numberOfLines={1}>{getName(item)}</Text>
<Text className="text-xs opacity-50">{getYear(item)}</Text>
</TouchableOpacity>
)}
/>
);
}

View File

@@ -0,0 +1,151 @@
import {
router,
useLocalSearchParams,
useSegments,
} from "expo-router";
import React, { useMemo } from "react";
import { TouchableOpacity } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Text } from "@/components/common/Text";
import { Image } from "expo-image";
import { OverviewText } from "@/components/OverviewText";
import { orderBy } from "lodash";
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
import Poster from "@/components/posters/Poster";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
export default function page() {
const local = useLocalSearchParams();
const segments = useSegments();
const { jellyseerrApi, jellyseerrUser } = useJellyseerr();
const { personId } = local as { personId: string };
const from = segments[2];
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(
() => jellyseerrApi
? castedRoles.map((c) => jellyseerrApi.imageProxy(c.backdropPath, "w1920_and_h800_multi_faces"))
: [],
[jellyseerrApi, data?.combinedCredits]
);
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 (
<ParallaxSlideShow
data={castedRoles}
images={backdrops}
listHeader="Appearances"
keyExtractor={(item) => item.id.toString()}
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,
}}
/>
}
HeaderContent={() => (
<>
<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>
</>
)}
MainContent={() => (
<OverviewText text={data?.details?.biography} className="mt-4" />
)}
renderItem={(item, index) => (
<TouchableOpacity
className="w-full flex flex-col pr-2"
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"}
/>
{item.character && (
<Text
className="text-xs opacity-50 align-bottom mt-1"
numberOfLines={1}
>
as {item.character}
</Text>
)}
</TouchableOpacity>
)}
/>
);
}

View File

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