feat(tv): seerr

This commit is contained in:
Fredrik Burmester
2026-01-20 22:15:00 +01:00
parent e3b4952c60
commit 0353a718f3
17 changed files with 1675 additions and 58 deletions

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -189,7 +189,7 @@ const styles = StyleSheet.create({
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 10,
paddingVertical: 20,
gap: 12,
},
cancelButtonContainer: {