mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
[Jellyseerr] Show genre/studio/network discover sliders
implements #326
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { type PropsWithChildren, type ReactElement } from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import {NativeScrollEvent, NativeSyntheticEvent, View, ViewProps} from "react-native";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
@@ -13,6 +13,7 @@ interface Props extends ViewProps {
|
||||
logo?: ReactElement;
|
||||
episodePoster?: ReactElement;
|
||||
headerHeight?: number;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
}
|
||||
|
||||
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
@@ -21,6 +22,7 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
episodePoster,
|
||||
headerHeight = 400,
|
||||
logo,
|
||||
onEndReached,
|
||||
...props
|
||||
}: Props) => {
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
@@ -47,6 +49,11 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) {
|
||||
return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1" {...props}>
|
||||
<Animated.ScrollView
|
||||
@@ -55,6 +62,10 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
|
||||
}}
|
||||
ref={scrollRef}
|
||||
scrollEventThrottle={16}
|
||||
onScroll={e => {
|
||||
if (isCloseToBottom(e.nativeEvent))
|
||||
onEndReached?.()
|
||||
}}
|
||||
>
|
||||
{logo && (
|
||||
<View
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
||||
import { sortBy } from "lodash";
|
||||
import { useMemo } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { View, ViewProps } from "react-native";
|
||||
import {
|
||||
useAnimatedReaction,
|
||||
@@ -19,8 +18,8 @@ import { Text } from "../common/Text";
|
||||
import JellyseerrPoster from "../posters/JellyseerrPoster";
|
||||
import { LoadingSkeleton } from "../search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "../search/SearchItemWrapper";
|
||||
import DiscoverSlide from "./DiscoverSlide";
|
||||
import PersonPoster from "./PersonPoster";
|
||||
import Discover from "@/components/jellyseerr/discover/Discover";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
searchQuery: string;
|
||||
@@ -101,12 +100,7 @@ export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
|
||||
if (!searchQuery.length)
|
||||
return (
|
||||
<View className="flex flex-col">
|
||||
{sortBy?.(
|
||||
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
||||
"order"
|
||||
).map((slide) => (
|
||||
<DiscoverSlide key={slide.id} slide={slide} />
|
||||
))}
|
||||
<Discover sliders={jellyseerrDiscoverSettings} />
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
155
components/jellyseerr/ParallaxSlideShow.tsx
Normal file
155
components/jellyseerr/ParallaxSlideShow.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, {
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {Dimensions, View, ViewProps} from "react-native";
|
||||
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 { FlashList } from "@shopify/flash-list";
|
||||
import {useFocusEffect} from "expo-router";
|
||||
|
||||
const ANIMATION_ENTER = 250;
|
||||
const ANIMATION_EXIT = 250;
|
||||
const BACKDROP_DURATION = 5000;
|
||||
|
||||
type Render = React.ComponentType<any>
|
||||
| React.ReactElement
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
interface Props<T> {
|
||||
data: T[]
|
||||
images: string[];
|
||||
logo?: React.ReactElement;
|
||||
HeaderContent?: () => React.ReactElement;
|
||||
MainContent?: () => React.ReactElement;
|
||||
listHeader: string;
|
||||
renderItem: (item: T, index: number) => Render;
|
||||
keyExtractor: (item: T) => string;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
}
|
||||
|
||||
const ParallaxSlideShow = <T extends unknown>({
|
||||
data,
|
||||
images,
|
||||
logo,
|
||||
HeaderContent,
|
||||
MainContent,
|
||||
listHeader,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
onEndReached,
|
||||
...props
|
||||
}: PropsWithChildren<Props<T> & ViewProps>
|
||||
) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [onEnd, setOnEnd] = useState<boolean>(true);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
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 (images?.length) {
|
||||
enterAnimation().start();
|
||||
const intervalId = setInterval(() => {
|
||||
exitAnimation().start((end) => {
|
||||
if (end.finished)
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
|
||||
});
|
||||
}, BACKDROP_DURATION);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
}, [images, enterAnimation, exitAnimation, setCurrentIndex, currentIndex]);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 relative"
|
||||
style={{
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className="flex-1 opacity-100"
|
||||
headerHeight={300}
|
||||
onEndReached={onEndReached}
|
||||
headerImage={
|
||||
<Animated.Image
|
||||
key={images?.[currentIndex]}
|
||||
id={images?.[currentIndex]}
|
||||
source={{
|
||||
uri: images?.[currentIndex],
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: fadeAnim,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
logo={logo}
|
||||
>
|
||||
<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">
|
||||
{HeaderContent && HeaderContent()}
|
||||
</View>
|
||||
</View>
|
||||
{MainContent && MainContent()}
|
||||
<View>
|
||||
<FlashList
|
||||
data={data}
|
||||
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">{listHeader}</Text>
|
||||
}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
//@ts-ignore
|
||||
renderItem={({ item, index}) => renderItem(item, index)}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={3}
|
||||
estimatedItemSize={214}
|
||||
ItemSeparatorComponent={() => <View className="h-2 w-2" />}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default ParallaxSlideShow;
|
||||
@@ -26,7 +26,7 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
|
||||
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<TouchableOpacity onPress={() => router.push(`/(auth)/(tabs)/${from}/jellyseerr/${id}`)}>
|
||||
<TouchableOpacity onPress={() => router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)}>
|
||||
<View className="flex flex-col w-28" {...props}>
|
||||
<Poster
|
||||
id={id}
|
||||
|
||||
41
components/jellyseerr/discover/CompanySlide.tsx
Normal file
41
components/jellyseerr/discover/CompanySlide.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React, {useCallback} from "react";
|
||||
import {
|
||||
useJellyseerr,
|
||||
} from "@/hooks/useJellyseerr";
|
||||
import {TouchableOpacity, ViewProps} from "react-native";
|
||||
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
|
||||
import {COMPANY_LOGO_IMAGE_FILTER, Network} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import {Studio} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
|
||||
import {router, useSegments} from "expo-router";
|
||||
|
||||
const CompanySlide: React.FC<{data: Network[] | Studio[]} & SlideProps & ViewProps> = ({ slide, data, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const from = segments[2];
|
||||
|
||||
const navigate = useCallback(({id, image, name}: Network | Studio) => router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}`,
|
||||
params: {id, image, name, type: slide.type }
|
||||
}), [slide]);
|
||||
|
||||
return (
|
||||
<Slide
|
||||
{...props}
|
||||
slide={slide}
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
|
||||
<GenericSlideCard
|
||||
className="w-28 rounded-lg overflow-hidden border border-neutral-900 p-4"
|
||||
id={item.id.toString()}
|
||||
url={jellyseerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanySlide;
|
||||
47
components/jellyseerr/discover/Discover.tsx
Normal file
47
components/jellyseerr/discover/Discover.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React, {useMemo} from "react";
|
||||
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
||||
import {sortBy} from "lodash";
|
||||
import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
|
||||
import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
|
||||
import {View} from "react-native";
|
||||
import {networks} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
import {studios} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
|
||||
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
|
||||
|
||||
interface Props {
|
||||
sliders?: DiscoverSlider[];
|
||||
}
|
||||
const Discover: React.FC<Props> = ({ sliders }) => {
|
||||
if (!sliders)
|
||||
return;
|
||||
|
||||
const sortedSliders = useMemo(
|
||||
() => sortBy(sliders.filter((s) => s.enabled), 'order', 'asc'),
|
||||
[sliders]
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex flex-col space-y-4 mb-8">
|
||||
{sortedSliders.map(slide => {
|
||||
switch (slide.type) {
|
||||
case DiscoverSliderType.NETWORKS:
|
||||
return <CompanySlide key={slide.id} slide={slide} data={networks}/>
|
||||
case DiscoverSliderType.STUDIOS:
|
||||
return <CompanySlide key={slide.id} slide={slide} data={studios}/>
|
||||
case DiscoverSliderType.MOVIE_GENRES:
|
||||
case DiscoverSliderType.TV_GENRES:
|
||||
return <GenreSlide key={slide.id} slide={slide} />
|
||||
case DiscoverSliderType.TRENDING:
|
||||
case DiscoverSliderType.POPULAR_MOVIES:
|
||||
case DiscoverSliderType.UPCOMING_MOVIES:
|
||||
case DiscoverSliderType.POPULAR_TV:
|
||||
case DiscoverSliderType.UPCOMING_TV:
|
||||
return <MovieTvSlide key={slide.id} slide={slide}/>
|
||||
}
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
};
|
||||
|
||||
export default Discover;
|
||||
59
components/jellyseerr/discover/GenericSlideCard.tsx
Normal file
59
components/jellyseerr/discover/GenericSlideCard.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import {StyleSheet, View, ViewProps} from "react-native";
|
||||
import {Image, ImageContentFit} from "expo-image";
|
||||
import {Text} from "@/components/common/Text";
|
||||
import {LinearGradient} from "expo-linear-gradient";
|
||||
|
||||
export const textShadowStyle = StyleSheet.create({
|
||||
shadow: {
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: .5,
|
||||
|
||||
elevation: 6,
|
||||
}
|
||||
})
|
||||
|
||||
const GenericSlideCard: React.FC<{id: string; url?: string, title?: string, colors?: string[], contentFit?: ImageContentFit} & ViewProps> = ({
|
||||
id,
|
||||
url,
|
||||
title,
|
||||
colors = ['#9333ea', 'transparent'],
|
||||
contentFit = "contain",
|
||||
...props
|
||||
}) => (
|
||||
<>
|
||||
<LinearGradient
|
||||
colors={colors}
|
||||
start={{x: 0.5, y: 1.75}}
|
||||
end={{x: 0.5, y: 0}}
|
||||
className="rounded-xl"
|
||||
>
|
||||
<View className="rounded-xl" {...props}>
|
||||
<Image
|
||||
key={id}
|
||||
id={id}
|
||||
source={url ? {uri: url} : null}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit={contentFit}
|
||||
style={{
|
||||
aspectRatio: "4/3",
|
||||
}}
|
||||
/>
|
||||
{title &&
|
||||
<View
|
||||
className="absolute justify-center top-0 left-0 right-0 bottom-0 items-center"
|
||||
>
|
||||
<Text className="text-center font-bold" style={textShadowStyle.shadow}>{title}</Text>
|
||||
</View>
|
||||
}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</>
|
||||
);
|
||||
|
||||
export default GenericSlideCard;
|
||||
56
components/jellyseerr/discover/GenreSlide.tsx
Normal file
56
components/jellyseerr/discover/GenreSlide.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, {useCallback} from "react";
|
||||
import {Endpoints, useJellyseerr,} from "@/hooks/useJellyseerr";
|
||||
import {TouchableOpacity, ViewProps} from "react-native";
|
||||
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import {router, useSegments} from "expo-router";
|
||||
import {useQuery} from "@tanstack/react-query";
|
||||
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
|
||||
import {genreColorMap} from "@/utils/jellyseerr/src/components/Discover/constants";
|
||||
import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
|
||||
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const from = segments[2];
|
||||
|
||||
const navigate = useCallback((genre: GenreSliderItem) => router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}`,
|
||||
params: {type: slide.type, name: genre.name}
|
||||
}), [slide]);
|
||||
|
||||
const {data, isFetching, isLoading } = useQuery({
|
||||
queryKey: ['jellyseerr', 'discover', slide.type, slide.id],
|
||||
queryFn: async () => {
|
||||
return jellyseerrApi?.getGenreSliders(
|
||||
slide.type == DiscoverSliderType.MOVIE_GENRES
|
||||
? Endpoints.MOVIE
|
||||
: Endpoints.TV
|
||||
)
|
||||
},
|
||||
enabled: !!jellyseerrApi
|
||||
})
|
||||
|
||||
return (
|
||||
data && <Slide
|
||||
{...props}
|
||||
slide={slide}
|
||||
data={data}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity className="mr-2" onPress={() => navigate(item)}>
|
||||
<GenericSlideCard
|
||||
className="w-28 rounded-lg overflow-hidden border border-neutral-900"
|
||||
id={item.id.toString()}
|
||||
title={item.name}
|
||||
colors={[]}
|
||||
contentFit={"cover"}
|
||||
url={jellyseerrApi?.imageProxy(item.backdrops?.[0], `w780_filter(duotone,${genreColorMap[item.id] ?? genreColorMap[0]})`)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenreSlide;
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useMemo } from "react";
|
||||
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import React, {useMemo} from "react";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import {
|
||||
DiscoverEndpoint,
|
||||
@@ -9,17 +8,13 @@ import {
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import { View } from "react-native";
|
||||
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
|
||||
import {ViewProps} from "react-native";
|
||||
|
||||
interface Props {
|
||||
slide: DiscoverSlider;
|
||||
}
|
||||
const DiscoverSlide: React.FC<Props> = ({ slide }) => {
|
||||
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ["jellyseerr", "discover", slide.id],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
let endpoint: DiscoverEndpoint | undefined = undefined;
|
||||
@@ -62,42 +57,28 @@ const DiscoverSlide: React.FC<Props> = ({ slide }) => {
|
||||
});
|
||||
|
||||
const flatData = useMemo(
|
||||
() =>
|
||||
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
|
||||
() => data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
flatData &&
|
||||
flatData?.length > 0 && (
|
||||
<View className="mb-4">
|
||||
<Text className="font-bold text-lg mb-2 px-4">
|
||||
{DiscoverSliderType[slide.type].toString().toTitle()}
|
||||
</Text>
|
||||
<FlashList
|
||||
horizontal
|
||||
contentContainerStyle={{
|
||||
paddingLeft: 16,
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={(item) => item!!.id.toString()}
|
||||
estimatedItemSize={250}
|
||||
data={flatData}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) fetchNextPage();
|
||||
}}
|
||||
renderItem={({ item }) =>
|
||||
item ? (
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Slide
|
||||
{...props}
|
||||
slide={slide}
|
||||
data={flatData}
|
||||
keyExtractor={(item) => item!!.id.toString()}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage)
|
||||
fetchNextPage()
|
||||
}}
|
||||
renderItem={(item) =>
|
||||
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverSlide;
|
||||
export default MovieTvSlide;
|
||||
55
components/jellyseerr/discover/Slide.tsx
Normal file
55
components/jellyseerr/discover/Slide.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, {PropsWithChildren} from "react";
|
||||
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import {View, ViewProps} from "react-native";
|
||||
|
||||
export interface SlideProps {
|
||||
slide: DiscoverSlider;
|
||||
}
|
||||
|
||||
interface Props<T> extends SlideProps {
|
||||
data: T[]
|
||||
renderItem: (item: T, index: number) =>
|
||||
| React.ComponentType<any>
|
||||
| React.ReactElement
|
||||
| null
|
||||
| undefined;
|
||||
keyExtractor: (item: T) => string;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
}
|
||||
|
||||
const Slide = <T extends unknown>({
|
||||
data,
|
||||
slide,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
onEndReached,
|
||||
...props
|
||||
}: PropsWithChildren<Props<T> & ViewProps>
|
||||
) => {
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className="font-bold text-lg mb-2 px-4">
|
||||
{DiscoverSliderType[slide.type].toString().toTitle()}
|
||||
</Text>
|
||||
<FlashList
|
||||
horizontal
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: 16,
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemSize={250}
|
||||
data={data}
|
||||
onEndReachedThreshold={1}
|
||||
onEndReached={onEndReached}
|
||||
//@ts-ignore
|
||||
renderItem={({item, index}) => item ? renderItem(item, index) : <></>}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Slide;
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
PersonDetails,
|
||||
} from "@/utils/jellyseerr/server/models/Person";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {GenreSliderItem} from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
|
||||
interface SearchParams {
|
||||
query: string;
|
||||
@@ -67,14 +68,20 @@ export enum Endpoints {
|
||||
ISSUE = "/issue",
|
||||
TV = "/tv",
|
||||
SETTINGS = "/settings",
|
||||
NETWORK = "/network",
|
||||
STUDIO = "/studio",
|
||||
GENRE_SLIDER = "/genreslider",
|
||||
DISCOVER = "/discover",
|
||||
DISCOVER_TRENDING = DISCOVER + "/trending",
|
||||
DISCOVER_MOVIES = DISCOVER + "/movies",
|
||||
DISCOVER_TV = DISCOVER + TV,
|
||||
DISCOVER_TV_NETWORK = DISCOVER + TV + NETWORK,
|
||||
DISCOVER_MOVIES_STUDIO = DISCOVER + `${MOVIE}s` + STUDIO,
|
||||
AUTH_JELLYFIN = "/auth/jellyfin",
|
||||
}
|
||||
|
||||
export type DiscoverEndpoint =
|
||||
| Endpoints.DISCOVER_TV_NETWORK
|
||||
| Endpoints.DISCOVER_TRENDING
|
||||
| Endpoints.DISCOVER_MOVIES
|
||||
| Endpoints.DISCOVER_TV;
|
||||
@@ -181,7 +188,7 @@ export class JellyseerrApi {
|
||||
}
|
||||
|
||||
async discover(
|
||||
endpoint: DiscoverEndpoint,
|
||||
endpoint: DiscoverEndpoint | string,
|
||||
params: any
|
||||
): Promise<SearchResults> {
|
||||
return this.axios
|
||||
@@ -189,6 +196,15 @@ export class JellyseerrApi {
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async getGenreSliders(
|
||||
endpoint: Endpoints.TV | Endpoints.MOVIE,
|
||||
params: any = undefined
|
||||
): Promise<GenreSliderItem[]> {
|
||||
return this.axios
|
||||
?.get<GenreSliderItem[]>(Endpoints.API_V1 + Endpoints.DISCOVER + Endpoints.GENRE_SLIDER + endpoint, { params })
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async search(params: SearchParams): Promise<SearchResults> {
|
||||
const response = await this.axios?.get<SearchResults>(
|
||||
Endpoints.API_V1 + Endpoints.SEARCH,
|
||||
@@ -268,7 +284,7 @@ export class JellyseerrApi {
|
||||
|
||||
imageProxy(
|
||||
path?: string,
|
||||
tmdbPath: string = "original",
|
||||
filter: string = "original",
|
||||
width: number = 1920,
|
||||
quality: number = 75
|
||||
) {
|
||||
@@ -276,7 +292,7 @@ export class JellyseerrApi {
|
||||
? this.axios.defaults.baseURL +
|
||||
`/_next/image?` +
|
||||
new URLSearchParams(
|
||||
`url=https://image.tmdb.org/t/p/${tmdbPath}/${path}&w=${width}&q=${quality}`
|
||||
`url=https://image.tmdb.org/t/p/${filter}/${path}&w=${width}&q=${quality}`
|
||||
).toString()
|
||||
: this.axios?.defaults.baseURL +
|
||||
`/images/overseerr_poster_not_found_logo_top.png`;
|
||||
|
||||
Submodule utils/jellyseerr updated: e69d160e25...a15f2ab336
Reference in New Issue
Block a user