mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-23 17:24:42 +01:00
feat(tv): seerr
This commit is contained in:
47
components/jellyseerr/discover/TVDiscover.tsx
Normal file
47
components/jellyseerr/discover/TVDiscover.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { sortBy } from "lodash";
|
||||
import React, { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import { TVDiscoverSlide } from "./TVDiscoverSlide";
|
||||
|
||||
interface TVDiscoverProps {
|
||||
sliders?: DiscoverSlider[];
|
||||
}
|
||||
|
||||
// Only show movie/TV slides on TV - skip genres, networks, studios for now
|
||||
const SUPPORTED_SLIDE_TYPES = [
|
||||
DiscoverSliderType.TRENDING,
|
||||
DiscoverSliderType.POPULAR_MOVIES,
|
||||
DiscoverSliderType.UPCOMING_MOVIES,
|
||||
DiscoverSliderType.POPULAR_TV,
|
||||
DiscoverSliderType.UPCOMING_TV,
|
||||
];
|
||||
|
||||
export const TVDiscover: React.FC<TVDiscoverProps> = ({ sliders }) => {
|
||||
const sortedSliders = useMemo(
|
||||
() =>
|
||||
sortBy(
|
||||
(sliders ?? []).filter(
|
||||
(s) => s.enabled && SUPPORTED_SLIDE_TYPES.includes(s.type),
|
||||
),
|
||||
"order",
|
||||
"asc",
|
||||
),
|
||||
[sliders],
|
||||
);
|
||||
|
||||
if (!sliders || sortedSliders.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
{sortedSliders.map((slide, index) => (
|
||||
<TVDiscoverSlide
|
||||
key={slide.id}
|
||||
slide={slide}
|
||||
isFirstSlide={index === 0}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
249
components/jellyseerr/discover/TVDiscoverSlide.tsx
Normal file
249
components/jellyseerr/discover/TVDiscoverSlide.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
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 { TVTypography } 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 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 router = useRouter();
|
||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
|
||||
const posterUrl = item.posterPath
|
||||
? jellyseerrApi?.imageProxy(item.posterPath, "w342")
|
||||
: null;
|
||||
|
||||
const title = getTitle(item);
|
||||
const year = getYear(item);
|
||||
|
||||
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: 180,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 180,
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "600",
|
||||
marginTop: 10,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{year && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: focused
|
||||
? "rgba(255,255,255,0.7)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVDiscoverSlideProps {
|
||||
slide: DiscoverSlider;
|
||||
isFirstSlide?: boolean;
|
||||
}
|
||||
|
||||
export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
slide,
|
||||
isFirstSlide = false,
|
||||
}) => {
|
||||
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: TVTypography.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>
|
||||
);
|
||||
};
|
||||
430
components/search/TVJellyseerrSearchResults.tsx
Normal file
430
components/search/TVJellyseerrSearchResults.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import React 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 { TVTypography } from "@/constants/TVTypography";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import type {
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
interface TVJellyseerrPosterProps {
|
||||
item: MovieResult | TvResult;
|
||||
onPress: () => void;
|
||||
isFirstItem?: boolean;
|
||||
}
|
||||
|
||||
const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
item,
|
||||
onPress,
|
||||
isFirstItem = false,
|
||||
}) => {
|
||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
|
||||
const posterUrl = item.posterPath
|
||||
? jellyseerrApi?.imageProxy(item.posterPath, "w342")
|
||||
: null;
|
||||
|
||||
const title = getTitle(item);
|
||||
const year = getYear(item);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={isFirstItem}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 210,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "600",
|
||||
marginTop: 10,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{year && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: focused
|
||||
? "rgba(255,255,255,0.7)"
|
||||
: "rgba(255,255,255,0.5)",
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVJellyseerrPersonPosterProps {
|
||||
item: PersonResult;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
item,
|
||||
onPress,
|
||||
}) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
|
||||
const posterUrl = item.profilePath
|
||||
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 160,
|
||||
alignItems: "center",
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
borderWidth: focused ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
}}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<Image
|
||||
source={{ uri: posterUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "600",
|
||||
marginTop: 12,
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVJellyseerrMovieSectionProps {
|
||||
title: string;
|
||||
items: MovieResult[];
|
||||
isFirstSection?: boolean;
|
||||
onItemPress: (item: MovieResult) => void;
|
||||
}
|
||||
|
||||
const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||
title,
|
||||
items,
|
||||
isFirstSection = false,
|
||||
onItemPress,
|
||||
}) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item, index }) => (
|
||||
<TVJellyseerrPoster
|
||||
item={item}
|
||||
onPress={() => onItemPress(item)}
|
||||
isFirstItem={isFirstSection && index === 0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVJellyseerrTvSectionProps {
|
||||
title: string;
|
||||
items: TvResult[];
|
||||
isFirstSection?: boolean;
|
||||
onItemPress: (item: TvResult) => void;
|
||||
}
|
||||
|
||||
const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||
title,
|
||||
items,
|
||||
isFirstSection = false,
|
||||
onItemPress,
|
||||
}) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item, index }) => (
|
||||
<TVJellyseerrPoster
|
||||
item={item}
|
||||
onPress={() => onItemPress(item)}
|
||||
isFirstItem={isFirstSection && index === 0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface TVJellyseerrPersonSectionProps {
|
||||
title: string;
|
||||
items: PersonResult[];
|
||||
isFirstSection?: boolean;
|
||||
onItemPress: (item: PersonResult) => void;
|
||||
}
|
||||
|
||||
const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||
title,
|
||||
items,
|
||||
isFirstSection: _isFirstSection = false,
|
||||
onItemPress,
|
||||
}) => {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: TVTypography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item }) => (
|
||||
<TVJellyseerrPersonPoster
|
||||
item={item}
|
||||
onPress={() => onItemPress(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TVJellyseerrSearchResultsProps {
|
||||
movieResults: MovieResult[];
|
||||
tvResults: TvResult[];
|
||||
personResults: PersonResult[];
|
||||
loading: boolean;
|
||||
noResults: boolean;
|
||||
searchQuery: string;
|
||||
onMoviePress: (item: MovieResult) => void;
|
||||
onTvPress: (item: TvResult) => void;
|
||||
onPersonPress: (item: PersonResult) => void;
|
||||
}
|
||||
|
||||
export const TVJellyseerrSearchResults: React.FC<
|
||||
TVJellyseerrSearchResultsProps
|
||||
> = ({
|
||||
movieResults,
|
||||
tvResults,
|
||||
personResults,
|
||||
loading,
|
||||
noResults,
|
||||
searchQuery,
|
||||
onMoviePress,
|
||||
onTvPress,
|
||||
onPersonPress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasMovies = movieResults && movieResults.length > 0;
|
||||
const hasTv = tvResults && tvResults.length > 0;
|
||||
const hasPersons = personResults && personResults.length > 0;
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (noResults && searchQuery.length > 0) {
|
||||
return (
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||
"{searchQuery}"
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TVJellyseerrMovieSection
|
||||
title={t("search.request_movies")}
|
||||
items={movieResults}
|
||||
isFirstSection={hasMovies}
|
||||
onItemPress={onMoviePress}
|
||||
/>
|
||||
<TVJellyseerrTvSection
|
||||
title={t("search.request_series")}
|
||||
items={tvResults}
|
||||
isFirstSection={!hasMovies && hasTv}
|
||||
onItemPress={onTvPress}
|
||||
/>
|
||||
<TVJellyseerrPersonSection
|
||||
title={t("search.actors")}
|
||||
items={personResults}
|
||||
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
||||
onItemPress={onPersonPress}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -6,10 +6,18 @@ import { ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { TVSearchBadge } from "./TVSearchBadge";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import type {
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
|
||||
import { TVSearchSection } from "./TVSearchSection";
|
||||
import { TVSearchTabBadges } from "./TVSearchTabBadges";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
@@ -77,20 +85,13 @@ const TVLoadingSkeleton: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Example search suggestions for TV
|
||||
const exampleSearches = [
|
||||
"Lord of the rings",
|
||||
"Avengers",
|
||||
"Game of Thrones",
|
||||
"Breaking Bad",
|
||||
"Stranger Things",
|
||||
"The Mandalorian",
|
||||
];
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
interface TVSearchPageProps {
|
||||
search: string;
|
||||
setSearch: (text: string) => void;
|
||||
debouncedSearch: string;
|
||||
// Library search results
|
||||
movies?: BaseItemDto[];
|
||||
series?: BaseItemDto[];
|
||||
episodes?: BaseItemDto[];
|
||||
@@ -103,6 +104,20 @@ interface TVSearchPageProps {
|
||||
loading: boolean;
|
||||
noResults: boolean;
|
||||
onItemPress: (item: BaseItemDto) => void;
|
||||
// Jellyseerr/Discover props
|
||||
searchType: SearchType;
|
||||
setSearchType: (type: SearchType) => void;
|
||||
showDiscover: boolean;
|
||||
jellyseerrMovies?: MovieResult[];
|
||||
jellyseerrTv?: TvResult[];
|
||||
jellyseerrPersons?: PersonResult[];
|
||||
jellyseerrLoading?: boolean;
|
||||
jellyseerrNoResults?: boolean;
|
||||
onJellyseerrMoviePress?: (item: MovieResult) => void;
|
||||
onJellyseerrTvPress?: (item: TvResult) => void;
|
||||
onJellyseerrPersonPress?: (item: PersonResult) => void;
|
||||
// Discover sliders for empty state
|
||||
discoverSliders?: DiscoverSlider[];
|
||||
}
|
||||
|
||||
export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
@@ -121,6 +136,18 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
loading,
|
||||
noResults,
|
||||
onItemPress,
|
||||
searchType,
|
||||
setSearchType,
|
||||
showDiscover,
|
||||
jellyseerrMovies = [],
|
||||
jellyseerrTv = [],
|
||||
jellyseerrPersons = [],
|
||||
jellyseerrLoading = false,
|
||||
jellyseerrNoResults = false,
|
||||
onJellyseerrMoviePress,
|
||||
onJellyseerrTvPress,
|
||||
onJellyseerrPersonPress,
|
||||
discoverSliders,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -177,6 +204,11 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
const isLibraryMode = searchType === "Library";
|
||||
const isDiscoverMode = searchType === "Discover";
|
||||
const currentLoading = isLibraryMode ? loading : jellyseerrLoading;
|
||||
const currentNoResults = isLibraryMode ? noResults : jellyseerrNoResults;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
@@ -190,7 +222,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
}}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<View style={{ marginBottom: 32, marginHorizontal: SCALE_PADDING }}>
|
||||
<View style={{ marginBottom: 24, marginHorizontal: SCALE_PADDING }}>
|
||||
<Input
|
||||
placeholder={t("search.search")}
|
||||
value={search}
|
||||
@@ -201,21 +233,34 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
clearButtonMode='while-editing'
|
||||
maxLength={500}
|
||||
hasTVPreferredFocus={
|
||||
debouncedSearch.length === 0 && sections.length === 0
|
||||
debouncedSearch.length === 0 &&
|
||||
sections.length === 0 &&
|
||||
!showDiscover
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Search Type Tab Badges */}
|
||||
{showDiscover && (
|
||||
<View style={{ marginHorizontal: SCALE_PADDING }}>
|
||||
<TVSearchTabBadges
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
showDiscover={showDiscover}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
{currentLoading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
<TVLoadingSkeleton />
|
||||
<TVLoadingSkeleton />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Search Results */}
|
||||
{!loading && (
|
||||
{/* Library Search Results */}
|
||||
{isLibraryMode && !loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section, index) => (
|
||||
<TVSearchSection
|
||||
@@ -237,8 +282,28 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Jellyseerr/Discover Search Results */}
|
||||
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length > 0 && (
|
||||
<TVJellyseerrSearchResults
|
||||
movieResults={jellyseerrMovies}
|
||||
tvResults={jellyseerrTv}
|
||||
personResults={jellyseerrPersons}
|
||||
loading={jellyseerrLoading}
|
||||
noResults={jellyseerrNoResults}
|
||||
searchQuery={debouncedSearch}
|
||||
onMoviePress={onJellyseerrMoviePress || (() => {})}
|
||||
onTvPress={onJellyseerrTvPress || (() => {})}
|
||||
onPersonPress={onJellyseerrPersonPress || (() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Discover Content (when no search query in Discover mode) */}
|
||||
{isDiscoverMode && !jellyseerrLoading && debouncedSearch.length === 0 && (
|
||||
<TVDiscover sliders={discoverSliders} />
|
||||
)}
|
||||
|
||||
{/* No Results State */}
|
||||
{!loading && noResults && debouncedSearch.length > 0 && (
|
||||
{!currentLoading && currentNoResults && debouncedSearch.length > 0 && (
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
@@ -250,33 +315,11 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 18, color: "#9334E9" }}>
|
||||
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||
"{debouncedSearch}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Example Searches (when no search query) */}
|
||||
{!loading && debouncedSearch.length === 0 && (
|
||||
<View style={{ alignItems: "center", paddingTop: 40, gap: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{exampleSearches.map((example) => (
|
||||
<TVSearchBadge
|
||||
key={example}
|
||||
label={example}
|
||||
onPress={() => setSearch(example)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
115
components/search/TVSearchTabBadges.tsx
Normal file
115
components/search/TVSearchTabBadges.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { Animated, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
|
||||
type SearchType = "Library" | "Discover";
|
||||
|
||||
interface TVSearchTabBadgeProps {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
hasTVPreferredFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const TVSearchTabBadge: React.FC<TVSearchTabBadgeProps> = ({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
hasTVPreferredFocus = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08, duration: 150 });
|
||||
|
||||
// Design language: white for focused/selected, transparent white for unfocused
|
||||
const getBackgroundColor = () => {
|
||||
if (focused) return "#fff";
|
||||
if (isSelected) return "rgba(255,255,255,0.25)";
|
||||
return "rgba(255,255,255,0.1)";
|
||||
};
|
||||
|
||||
const getTextColor = () => {
|
||||
if (focused) return "#000";
|
||||
return "#fff";
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
|
||||
disabled={disabled}
|
||||
focusable={!disabled}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 24,
|
||||
backgroundColor: getBackgroundColor(),
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.4 : 0,
|
||||
shadowRadius: focused ? 12 : 0,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: getTextColor(),
|
||||
fontWeight: isSelected || focused ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TVSearchTabBadgesProps {
|
||||
searchType: SearchType;
|
||||
setSearchType: (type: SearchType) => void;
|
||||
showDiscover: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TVSearchTabBadges: React.FC<TVSearchTabBadgesProps> = ({
|
||||
searchType,
|
||||
setSearchType,
|
||||
showDiscover,
|
||||
disabled = false,
|
||||
}) => {
|
||||
if (!showDiscover) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<TVSearchTabBadge
|
||||
label='Library'
|
||||
isSelected={searchType === "Library"}
|
||||
onPress={() => setSearchType("Library")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TVSearchTabBadge
|
||||
label='Discover'
|
||||
isSelected={searchType === "Discover"}
|
||||
onPress={() => setSearchType("Discover")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
TVEpisodeCard,
|
||||
} from "@/components/series/TVEpisodeCard";
|
||||
import { TVSeriesHeader } from "@/components/series/TVSeriesHeader";
|
||||
import { TVOptionSelector } from "@/components/tv/TVOptionSelector";
|
||||
import { TVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
@@ -229,8 +229,8 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
[item.Id, seasonIndexState],
|
||||
);
|
||||
|
||||
// TV option modal hook
|
||||
const { showOptions } = useTVOptionModal();
|
||||
// Season selector modal state
|
||||
const [isSeasonModalVisible, setIsSeasonModalVisible] = useState(false);
|
||||
|
||||
// ScrollView ref for page scrolling
|
||||
const mainScrollRef = useRef<ScrollView>(null);
|
||||
@@ -403,22 +403,24 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
|
||||
// Open season modal
|
||||
const handleOpenSeasonModal = useCallback(() => {
|
||||
const options = seasons.map((season: BaseItemDto) => ({
|
||||
setIsSeasonModalVisible(true);
|
||||
}, []);
|
||||
|
||||
// Close season modal
|
||||
const handleCloseSeasonModal = useCallback(() => {
|
||||
setIsSeasonModalVisible(false);
|
||||
}, []);
|
||||
|
||||
// Season options for the modal
|
||||
const seasonOptions = useMemo(() => {
|
||||
return seasons.map((season: BaseItemDto) => ({
|
||||
label: season.Name || `Season ${season.IndexNumber}`,
|
||||
value: season.IndexNumber ?? 0,
|
||||
selected:
|
||||
season.IndexNumber === selectedSeasonIndex ||
|
||||
season.Name === String(selectedSeasonIndex),
|
||||
}));
|
||||
|
||||
showOptions({
|
||||
title: t("item_card.select_season"),
|
||||
options,
|
||||
onSelect: handleSeasonSelect,
|
||||
cardWidth: 180,
|
||||
cardHeight: 85,
|
||||
});
|
||||
}, [seasons, selectedSeasonIndex, showOptions, t, handleSeasonSelect]);
|
||||
}, [seasons, selectedSeasonIndex]);
|
||||
|
||||
// Episode list item layout
|
||||
const getItemLayout = useCallback(
|
||||
@@ -439,10 +441,16 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
onPress={() => handleEpisodePress(episode)}
|
||||
onFocus={handleEpisodeFocus}
|
||||
onBlur={handleEpisodeBlur}
|
||||
disabled={isSeasonModalVisible}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[handleEpisodePress, handleEpisodeFocus, handleEpisodeBlur],
|
||||
[
|
||||
handleEpisodePress,
|
||||
handleEpisodeFocus,
|
||||
handleEpisodeBlur,
|
||||
isSeasonModalVisible,
|
||||
],
|
||||
);
|
||||
|
||||
// Get play button text
|
||||
@@ -563,7 +571,8 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
>
|
||||
<TVFocusableButton
|
||||
onPress={handlePlayNextEpisode}
|
||||
hasTVPreferredFocus
|
||||
hasTVPreferredFocus={!isSeasonModalVisible}
|
||||
disabled={isSeasonModalVisible}
|
||||
variant='primary'
|
||||
>
|
||||
<Ionicons
|
||||
@@ -587,6 +596,7 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
<TVSeasonButton
|
||||
seasonName={selectedSeasonName}
|
||||
onPress={handleOpenSeasonModal}
|
||||
disabled={isSeasonModalVisible}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -638,6 +648,18 @@ export const TVSeriesPage: React.FC<TVSeriesPageProps> = ({
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Season selector modal */}
|
||||
<TVOptionSelector
|
||||
visible={isSeasonModalVisible}
|
||||
title={t("item_card.select_season")}
|
||||
options={seasonOptions}
|
||||
onSelect={handleSeasonSelect}
|
||||
onClose={handleCloseSeasonModal}
|
||||
cancelLabel={t("common.cancel")}
|
||||
cardWidth={180}
|
||||
cardHeight={85}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -189,7 +189,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: 48,
|
||||
paddingVertical: 10,
|
||||
paddingVertical: 20,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButtonContainer: {
|
||||
|
||||
Reference in New Issue
Block a user