diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx deleted file mode 100644 index 5a930982..00000000 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/[personId].tsx +++ /dev/null @@ -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 ( - - - } - 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}*/} - {item.character && ( - - as {item.character} - - )} - - )} - keyExtractor={(item) => item.id.toString()} - estimatedItemSize={255} - numColumns={3} - contentContainerStyle={{ paddingBottom: 24 }} - ItemSeparatorComponent={() => } - /> - - - - - ); -} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx new file mode 100644 index 00000000..02762a51 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/company/[companyId].tsx @@ -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 ( + item.id.toString()} + onEndReached={() => { + if (hasNextPage) { + fetchNextPage() + } + }} + logo={ + + } + renderItem={(item, index) => ( + viewDetails(item)} + > + + + {getName(item)} + {getYear(item)} + + )} + /> + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx new file mode 100644 index 00000000..34a4fc7b --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/genre/[genreId].tsx @@ -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 ( + item.id.toString()} + onEndReached={() => { + if (hasNextPage) { + fetchNextPage() + } + }} + logo={ + + {name} + + } + renderItem={(item, index) => ( + viewDetails(item)} + > + + + {getName(item)} + {getYear(item)} + + )} + /> + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx new file mode 100644 index 00000000..84f59f4b --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/person/[personId].tsx @@ -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 ( + item.id.toString()} + logo={ + + } + HeaderContent={() => ( + <> + + {data?.details?.name} + + + Born{" "} + {new Date(data?.details?.birthday!!).toLocaleDateString( + `${locale}-${region}`, + { + year: "numeric", + month: "long", + day: "numeric", + } + )}{" "} + | {data?.details?.placeOfBirth} + + + )} + MainContent={() => ( + + )} + renderItem={(item, index) => ( + viewDetails(item)} + > + + + {item.character && ( + + as {item.character} + + )} + + )} + /> + ); +} diff --git a/app/(auth)/(tabs)/(search)/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx index 1119e2a4..1f6a3c8b 100644 --- a/app/(auth)/(tabs)/(search)/_layout.tsx +++ b/app/(auth)/(tabs)/(search)/_layout.tsx @@ -36,7 +36,9 @@ export default function SearchLayout() { }} /> - + + + ); } diff --git a/bun.lockb b/bun.lockb index 3a1947ce..ce47c71e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx index daebca6b..5d7b28e0 100644 --- a/components/ParallaxPage.tsx +++ b/components/ParallaxPage.tsx @@ -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> = ({ @@ -21,6 +22,7 @@ export const ParallaxScrollView: React.FC> = ({ episodePoster, headerHeight = 400, logo, + onEndReached, ...props }: Props) => { const scrollRef = useAnimatedRef(); @@ -47,6 +49,11 @@ export const ParallaxScrollView: React.FC> = ({ }; }); + + function isCloseToBottom({layoutMeasurement, contentOffset, contentSize}: NativeScrollEvent) { + return layoutMeasurement.height + contentOffset.y >= contentSize.height - 20; + } + return ( > = ({ }} ref={scrollRef} scrollEventThrottle={16} + onScroll={e => { + if (isCloseToBottom(e.nativeEvent)) + onEndReached?.() + }} > {logo && ( = ({ searchQuery }) => { if (!searchQuery.length) return ( - {sortBy?.( - jellyseerrDiscoverSettings?.filter((s) => s.enabled), - "order" - ).map((slide) => ( - - ))} + ); diff --git a/components/jellyseerr/ParallaxSlideShow.tsx b/components/jellyseerr/ParallaxSlideShow.tsx new file mode 100644 index 00000000..1e3d3f4f --- /dev/null +++ b/components/jellyseerr/ParallaxSlideShow.tsx @@ -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 + | React.ReactElement + | null + | undefined; + +interface Props { + 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 = ({ + data, + images, + logo, + HeaderContent, + MainContent, + listHeader, + renderItem, + keyExtractor, + onEndReached, + ...props +}: PropsWithChildren & ViewProps> +) => { + const insets = useSafeAreaInsets(); + + const [currentIndex, setCurrentIndex] = useState(0); + const [onEnd, setOnEnd] = useState(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 ( + + + } + logo={logo} + > + + + + {HeaderContent && HeaderContent()} + + + {MainContent && MainContent()} + + + + No results + + + } + contentInsetAdjustmentBehavior="automatic" + ListHeaderComponent={ + {listHeader} + } + nestedScrollEnabled + showsVerticalScrollIndicator={false} + //@ts-ignore + renderItem={({ item, index}) => renderItem(item, index)} + keyExtractor={keyExtractor} + numColumns={3} + estimatedItemSize={214} + ItemSeparatorComponent={() => } + /> + + + + + ); +} + +export default ParallaxSlideShow; \ No newline at end of file diff --git a/components/jellyseerr/PersonPoster.tsx b/components/jellyseerr/PersonPoster.tsx index 57ff9f58..6e7d9aa6 100644 --- a/components/jellyseerr/PersonPoster.tsx +++ b/components/jellyseerr/PersonPoster.tsx @@ -26,7 +26,7 @@ const PersonPoster: React.FC = ({ if (from === "(home)" || from === "(search)" || from === "(libraries)") return ( - router.push(`/(auth)/(tabs)/${from}/jellyseerr/${id}`)}> + router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)}> = ({ 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 ( + item.id.toString()} + renderItem={(item, index) => ( + navigate(item)}> + + + )} + /> + ); +}; + +export default CompanySlide; diff --git a/components/jellyseerr/discover/Discover.tsx b/components/jellyseerr/discover/Discover.tsx new file mode 100644 index 00000000..6270ad2b --- /dev/null +++ b/components/jellyseerr/discover/Discover.tsx @@ -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 = ({ sliders }) => { + if (!sliders) + return; + + const sortedSliders = useMemo( + () => sortBy(sliders.filter((s) => s.enabled), 'order', 'asc'), + [sliders] + ); + + return ( + + {sortedSliders.map(slide => { + switch (slide.type) { + case DiscoverSliderType.NETWORKS: + return + case DiscoverSliderType.STUDIOS: + return + case DiscoverSliderType.MOVIE_GENRES: + case DiscoverSliderType.TV_GENRES: + return + case DiscoverSliderType.TRENDING: + case DiscoverSliderType.POPULAR_MOVIES: + case DiscoverSliderType.UPCOMING_MOVIES: + case DiscoverSliderType.POPULAR_TV: + case DiscoverSliderType.UPCOMING_TV: + return + } + })} + + ) +}; + +export default Discover; diff --git a/components/jellyseerr/discover/GenericSlideCard.tsx b/components/jellyseerr/discover/GenericSlideCard.tsx new file mode 100644 index 00000000..776d1424 --- /dev/null +++ b/components/jellyseerr/discover/GenericSlideCard.tsx @@ -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 +}) => ( + <> + + + + {title && + + {title} + + } + + + +); + +export default GenericSlideCard; \ No newline at end of file diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/jellyseerr/discover/GenreSlide.tsx new file mode 100644 index 00000000..551ee2de --- /dev/null +++ b/components/jellyseerr/discover/GenreSlide.tsx @@ -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 = ({ 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 && item.id.toString()} + renderItem={(item, index) => ( + navigate(item)}> + + + )} + /> + ); +}; + +export default GenreSlide; diff --git a/components/jellyseerr/DiscoverSlide.tsx b/components/jellyseerr/discover/MovieTvSlide.tsx similarity index 58% rename from components/jellyseerr/DiscoverSlide.tsx rename to components/jellyseerr/discover/MovieTvSlide.tsx index c7112def..723658c8 100644 --- a/components/jellyseerr/DiscoverSlide.tsx +++ b/components/jellyseerr/discover/MovieTvSlide.tsx @@ -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 = ({ slide }) => { +const MovieTvSlide: React.FC = ({ 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 = ({ 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 && ( - - - {DiscoverSliderType[slide.type].toString().toTitle()} - - item!!.id.toString()} - estimatedItemSize={250} - data={flatData} - onEndReachedThreshold={1} - onEndReached={() => { - if (hasNextPage) fetchNextPage(); - }} - renderItem={({ item }) => - item ? ( - - ) : ( - <> - ) - } - /> - + item!!.id.toString()} + onEndReached={() => { + if (hasNextPage) + fetchNextPage() + }} + renderItem={(item) => + + } + /> ) ); }; -export default DiscoverSlide; +export default MovieTvSlide; diff --git a/components/jellyseerr/discover/Slide.tsx b/components/jellyseerr/discover/Slide.tsx new file mode 100644 index 00000000..5a593b41 --- /dev/null +++ b/components/jellyseerr/discover/Slide.tsx @@ -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 extends SlideProps { + data: T[] + renderItem: (item: T, index: number) => + | React.ComponentType + | React.ReactElement + | null + | undefined; + keyExtractor: (item: T) => string; + onEndReached?: (() => void) | null | undefined; +} + +const Slide = ({ + data, + slide, + renderItem, + keyExtractor, + onEndReached, + ...props +}: PropsWithChildren & ViewProps> +) => { + return ( + + + {DiscoverSliderType[slide.type].toString().toTitle()} + + item ? renderItem(item, index) : <>} + /> + + ); +}; + +export default Slide; diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index 815510fa..8d3eba30 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -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 { return this.axios @@ -189,6 +196,15 @@ export class JellyseerrApi { .then(({ data }) => data); } + async getGenreSliders( + endpoint: Endpoints.TV | Endpoints.MOVIE, + params: any = undefined + ): Promise { + return this.axios + ?.get(Endpoints.API_V1 + Endpoints.DISCOVER + Endpoints.GENRE_SLIDER + endpoint, { params }) + .then(({ data }) => data); + } + async search(params: SearchParams): Promise { const response = await this.axios?.get( 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`; diff --git a/utils/jellyseerr b/utils/jellyseerr index e69d160e..a15f2ab3 160000 --- a/utils/jellyseerr +++ b/utils/jellyseerr @@ -1 +1 @@ -Subproject commit e69d160e25f0962cd77b01c861ce248050e1ad38 +Subproject commit a15f2ab336936f49e38ea37f8b224da40e12588e