mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-29 17:20:30 +01:00
The TV search/discover page had three competing left-edge paddings: the Library/Discover badges used HORIZONTAL_PADDING (60), the Jellyseerr discover sections used SCALE_PADDING (20), while the rest of the app (home rows, library sections, loading skeleton) uses sizes.padding.horizontal. This left the filter badges visibly misaligned with the content grid below them. Unify the badges and the Jellyseerr discover/search sections onto sizes.padding.horizontal so the filter row, section headers, and posters share one edge — consistent with the home and library screens. Claude-Session: https://claude.ai/code/session_016Hhu5DruGLPhdP4LAoy1Xd
273 lines
7.6 KiB
TypeScript
273 lines
7.6 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 { useScaledTVSizes } from "@/constants/TVSizes";
|
|
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 sizes = useScaledTVSizes();
|
|
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: sizes.padding.horizontal,
|
|
}}
|
|
>
|
|
{slideTitle}
|
|
</Text>
|
|
<FlatList
|
|
horizontal
|
|
data={flatData}
|
|
keyExtractor={(item) => item.id.toString()}
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{
|
|
paddingHorizontal: sizes.padding.horizontal,
|
|
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>
|
|
);
|
|
};
|