Files
streamyfin/components/jellyseerr/discover/TVDiscoverSlide.tsx

271 lines
7.5 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { uniqBy } from "lodash";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import {
type DiscoverEndpoint,
Endpoints,
useJellyseerr,
} from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVDiscoverPosterProps {
item: MovieResult | TvResult;
isFirstItem?: boolean;
}
const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
item,
isFirstItem = false,
}) => {
const typography = useScaledTVTypography();
const router = useRouter();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
const posterUrl = item.posterPath
? jellyseerrApi?.imageProxy(item.posterPath, "w342")
: null;
const title = getTitle(item);
const year = getYear(item);
const isInLibrary =
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const handlePress = () => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
params: {
id: String(item.id),
mediaType: item.mediaType,
},
});
};
return (
<Pressable
onPress={handlePress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirstItem}
>
<Animated.View
style={[
animatedStyle,
{
width: 210,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
]}
>
<View
style={{
width: 210,
aspectRatio: 10 / 15,
borderRadius: 24,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
}}
>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#262626",
}}
>
<Ionicons
name='image-outline'
size={40}
color='rgba(255,255,255,0.3)'
/>
</View>
)}
{isInLibrary && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 14,
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons name='checkmark' size={18} color='black' />
</View>
)}
</View>
<Text
style={{
fontSize: typography.callout,
color: "#fff",
fontWeight: "600",
marginTop: 12,
}}
numberOfLines={2}
>
{title}
</Text>
{year && (
<Text
style={{
fontSize: typography.callout,
color: "#9CA3AF",
marginTop: 2,
}}
>
{year}
</Text>
)}
</Animated.View>
</Pressable>
);
};
interface TVDiscoverSlideProps {
slide: DiscoverSlider;
isFirstSlide?: boolean;
}
export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
slide,
isFirstSlide = false,
}) => {
const typography = useScaledTVTypography();
const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", "tv", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined;
let params: Record<string, unknown> = {
page: Number(pageParam),
};
switch (slide.type) {
case DiscoverSliderType.TRENDING:
endpoint = Endpoints.DISCOVER_TRENDING;
break;
case DiscoverSliderType.POPULAR_MOVIES:
case DiscoverSliderType.UPCOMING_MOVIES:
endpoint = Endpoints.DISCOVER_MOVIES;
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
params = {
...params,
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
};
break;
case DiscoverSliderType.POPULAR_TV:
case DiscoverSliderType.UPCOMING_TV:
endpoint = Endpoints.DISCOVER_TV;
if (slide.type === DiscoverSliderType.UPCOMING_TV)
params = {
...params,
firstAirDateGte: new Date().toISOString().split("T")[0],
};
break;
}
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
},
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
enabled: !!jellyseerrApi,
staleTime: 0,
});
const flatData = useMemo(
() =>
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) =>
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
),
"id",
) as (MovieResult | TvResult)[],
[data, isJellyseerrMovieOrTvResult],
);
const slideTitle = t(
`search.${DiscoverSliderType[slide.type].toString().toLowerCase()}`,
);
if (!flatData || flatData.length === 0) return null;
return (
<View style={{ marginBottom: 24 }}>
<Text
style={{
fontSize: typography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
marginLeft: SCALE_PADDING,
}}
>
{slideTitle}
</Text>
<FlatList
horizontal
data={flatData}
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: SCALE_PADDING,
paddingVertical: SCALE_PADDING,
gap: 20,
}}
style={{ overflow: "visible" }}
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
onEndReachedThreshold={0.5}
renderItem={({ item, index }) => (
<TVDiscoverPoster
item={item}
isFirstItem={isFirstSlide && index === 0}
/>
)}
/>
</View>
);
};