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

@@ -134,6 +134,11 @@ import { apiAtom } from "@/providers/JellyfinProvider";
- TV version uses `:tv` suffix for scripts
- Platform checks: `Platform.isTV`, `Platform.OS === "android"` or `"ios"`
- Some features disabled on TV (e.g., notifications, Chromecast)
- **TV Design**: Don't use purple accent colors on TV. Use white for focused states and `expo-blur` (`BlurView`) for backgrounds/overlays.
- **TV Typography**: Use `TVTypography` from `@/components/tv/TVTypography` for all text on TV. It provides consistent font sizes optimized for TV viewing distance.
- **TV Button Sizing**: Ensure buttons placed next to each other have the same size for visual consistency.
- **TV Focus Scale Padding**: Add sufficient padding around focusable items in tables/rows/columns/lists. The focus scale animation (typically 1.05x) will clip against parent containers without proper padding. Use `overflow: "visible"` on containers and add padding to prevent clipping.
- **TV Modals**: Never use overlay/absolute-positioned modals on TV as they don't handle the back button correctly. Instead, use the navigation-based modal pattern: create a Jotai atom for state, a hook that sets the atom and calls `router.push()`, and a page file in `app/(auth)/` that reads the atom and clears it on unmount. You must also add a `Stack.Screen` entry in `app/_layout.tsx` with `presentation: "transparentModal"` and `animation: "fade"` for the modal to render correctly as an overlay. See `useTVRequestModal` + `tv-request-modal.tsx` for reference.
### TV Component Rendering Pattern

View File

@@ -21,6 +21,7 @@ import { GenreTags } from "@/components/GenreTags";
import Cast from "@/components/jellyseerr/Cast";
import DetailFacts from "@/components/jellyseerr/DetailFacts";
import RequestModal from "@/components/jellyseerr/RequestModal";
import { TVJellyseerrPage } from "@/components/jellyseerr/tv";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { PlatformDropdown } from "@/components/PlatformDropdown";
@@ -52,7 +53,8 @@ import type {
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
const Page: React.FC = () => {
// Mobile page component
const MobilePage: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
@@ -542,4 +544,12 @@ const Page: React.FC = () => {
);
};
// Platform-conditional page component
const Page: React.FC = () => {
if (Platform.isTV) {
return <TVJellyseerrPage />;
}
return <MobilePage />;
};
export default Page;

View File

@@ -9,6 +9,7 @@ import axios from "axios";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
import { useAtom } from "jotai";
import { orderBy, uniqBy } from "lodash";
import {
useCallback,
useEffect,
@@ -45,6 +46,12 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
PersonResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import { createStreamystatsApi } from "@/utils/streamystats";
type SearchType = "Library" | "Discover";
@@ -452,6 +459,135 @@ export default function search() {
[from, router],
);
// Jellyseerr search for TV
const { data: jellyseerrTVResults, isFetching: jellyseerrTVLoading } =
useQuery({
queryKey: ["search", "jellyseerr", "tv", debouncedSearch],
queryFn: async () => {
const params = {
query: new URLSearchParams(debouncedSearch || "").toString(),
};
return await Promise.all([
jellyseerrApi?.search({ ...params, page: 1 }),
jellyseerrApi?.search({ ...params, page: 2 }),
jellyseerrApi?.search({ ...params, page: 3 }),
jellyseerrApi?.search({ ...params, page: 4 }),
]).then((all) =>
uniqBy(
all.flatMap((v) => v?.results || []),
"id",
),
);
},
enabled:
Platform.isTV &&
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length > 0,
});
// Process Jellyseerr results for TV
const jellyseerrMovieResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === MediaType.MOVIE,
) as MovieResult[],
[(m) => m?.title?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrTvResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === MediaType.TV,
) as TvResult[],
[(t) => t?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrPersonResults = useMemo(
() =>
orderBy(
jellyseerrTVResults?.filter(
(r) => r.mediaType === "person",
) as PersonResult[],
[(p) => p?.name?.toLowerCase() === debouncedSearch.toLowerCase()],
"desc",
),
[jellyseerrTVResults, debouncedSearch],
);
const jellyseerrTVNoResults = useMemo(() => {
return (
!jellyseerrMovieResults?.length &&
!jellyseerrTvResults?.length &&
!jellyseerrPersonResults?.length
);
}, [jellyseerrMovieResults, jellyseerrTvResults, jellyseerrPersonResults]);
// Fetch discover settings for TV (when no search query in Discover mode)
const { data: discoverSliders } = useQuery({
queryKey: ["search", "jellyseerr", "discoverSettings", "tv"],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled:
Platform.isTV &&
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length === 0,
});
// TV Jellyseerr press handlers
const handleJellyseerrMoviePress = useCallback(
(item: MovieResult) => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
params: {
mediaTitle: item.title,
releaseYear: String(new Date(item.releaseDate || "").getFullYear()),
canRequest: "true",
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
mediaType: MediaType.MOVIE,
id: String(item.id),
backdropPath: item.backdropPath || "",
overview: item.overview || "",
},
});
},
[router, jellyseerrApi],
);
const handleJellyseerrTvPress = useCallback(
(item: TvResult) => {
router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
params: {
mediaTitle: item.name,
releaseYear: String(new Date(item.firstAirDate || "").getFullYear()),
canRequest: "true",
posterSrc: jellyseerrApi?.imageProxy(item.posterPath) || "",
mediaType: MediaType.TV,
id: String(item.id),
backdropPath: item.backdropPath || "",
overview: item.overview || "",
},
});
},
[router, jellyseerrApi],
);
const handleJellyseerrPersonPress = useCallback(
(item: PersonResult) => {
router.push(`/(auth)/jellyseerr/person/${item.id}` as any);
},
[router],
);
// Render TV search page
if (Platform.isTV) {
return (
@@ -471,6 +607,18 @@ export default function search() {
loading={loading}
noResults={noResults}
onItemPress={handleItemPress}
searchType={searchType}
setSearchType={setSearchType}
showDiscover={!!jellyseerrApi}
jellyseerrMovies={jellyseerrMovieResults}
jellyseerrTv={jellyseerrTvResults}
jellyseerrPersons={jellyseerrPersonResults}
jellyseerrLoading={jellyseerrTVLoading}
jellyseerrNoResults={jellyseerrTVNoResults}
onJellyseerrMoviePress={handleJellyseerrMoviePress}
onJellyseerrTvPress={handleJellyseerrTvPress}
onJellyseerrPersonPress={handleJellyseerrPersonPress}
discoverSliders={discoverSliders}
/>
);
}

View File

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

View File

@@ -0,0 +1,489 @@
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { TVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
import type {
QualityProfile,
RootFolder,
Tag,
} from "@/utils/jellyseerr/server/api/servarr/base";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { store } from "@/utils/store";
export default function TVRequestModalPage() {
const router = useRouter();
const modalState = useAtomValue(tvRequestModalAtom);
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [isReady, setIsReady] = useState(false);
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
mediaId: modalState?.id ? Number(modalState.id) : 0,
mediaType: modalState?.mediaType,
userId: jellyseerrUser?.id,
});
const [activeSelector, setActiveSelector] = useState<
"profile" | "folder" | "user" | null
>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
// Animate in on mount
useEffect(() => {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => setIsReady(true), 100);
return () => {
clearTimeout(timer);
store.set(tvRequestModalAtom, null);
};
}, [overlayOpacity, sheetTranslateY]);
const { data: serviceSettings } = useQuery({
queryKey: ["jellyseerr", "request", modalState?.mediaType, "service"],
queryFn: async () =>
jellyseerrApi?.service(
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
),
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
});
const { data: users } = useQuery({
queryKey: ["jellyseerr", "users"],
queryFn: async () =>
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!jellyseerrApi && !!jellyseerrUser && !!modalState,
});
const defaultService = useMemo(
() => serviceSettings?.find?.((v) => v.isDefault),
[serviceSettings],
);
const { data: defaultServiceDetails } = useQuery({
queryKey: [
"jellyseerr",
"request",
modalState?.mediaType,
"service",
"details",
defaultService?.id,
],
queryFn: async () => {
setRequestOverrides((prev) => ({
...prev,
serverId: defaultService?.id,
}));
return jellyseerrApi?.serviceDetails(
modalState?.mediaType === "movie" ? "radarr" : "sonarr",
defaultService!.id,
);
},
enabled:
!!jellyseerrApi && !!jellyseerrUser && !!defaultService && !!modalState,
});
const defaultProfile: QualityProfile | undefined = useMemo(
() =>
defaultServiceDetails?.profiles.find(
(p) => p.id === defaultServiceDetails.server?.activeProfileId,
),
[defaultServiceDetails],
);
const defaultFolder: RootFolder | undefined = useMemo(
() =>
defaultServiceDetails?.rootFolders.find(
(f) => f.path === defaultServiceDetails.server?.activeDirectory,
),
[defaultServiceDetails],
);
const defaultTags: Tag[] = useMemo(() => {
return (
defaultServiceDetails?.tags.filter((t) =>
defaultServiceDetails?.server.activeTags?.includes(t.id),
) ?? []
);
}, [defaultServiceDetails]);
const pathTitleExtractor = (item: RootFolder) =>
`${item.path} (${item.freeSpace.bytesToReadable()})`;
// Option builders
const qualityProfileOptions: TVOptionItem<number>[] = useMemo(
() =>
defaultServiceDetails?.profiles.map((profile) => ({
label: profile.name,
value: profile.id,
selected:
(requestOverrides.profileId || defaultProfile?.id) === profile.id,
})) || [],
[
defaultServiceDetails?.profiles,
defaultProfile,
requestOverrides.profileId,
],
);
const rootFolderOptions: TVOptionItem<string>[] = useMemo(
() =>
defaultServiceDetails?.rootFolders.map((folder) => ({
label: pathTitleExtractor(folder),
value: folder.path,
selected:
(requestOverrides.rootFolder || defaultFolder?.path) === folder.path,
})) || [],
[
defaultServiceDetails?.rootFolders,
defaultFolder,
requestOverrides.rootFolder,
],
);
const userOptions: TVOptionItem<number>[] = useMemo(
() =>
users?.map((user) => ({
label: user.displayName,
value: user.id,
selected: (requestOverrides.userId || jellyseerrUser?.id) === user.id,
})) || [],
[users, jellyseerrUser, requestOverrides.userId],
);
const tagItems = useMemo(() => {
return (
defaultServiceDetails?.tags.map((tag) => ({
id: tag.id,
label: tag.label,
selected:
requestOverrides.tags?.includes(tag.id) ||
defaultTags.some((dt) => dt.id === tag.id),
})) ?? []
);
}, [defaultServiceDetails?.tags, defaultTags, requestOverrides.tags]);
// Selected display values
const selectedProfileName = useMemo(() => {
const profile = defaultServiceDetails?.profiles.find(
(p) => p.id === (requestOverrides.profileId || defaultProfile?.id),
);
return profile?.name || defaultProfile?.name || t("jellyseerr.select");
}, [
defaultServiceDetails?.profiles,
requestOverrides.profileId,
defaultProfile,
t,
]);
const selectedFolderName = useMemo(() => {
const folder = defaultServiceDetails?.rootFolders.find(
(f) => f.path === (requestOverrides.rootFolder || defaultFolder?.path),
);
return folder
? pathTitleExtractor(folder)
: defaultFolder
? pathTitleExtractor(defaultFolder)
: t("jellyseerr.select");
}, [
defaultServiceDetails?.rootFolders,
requestOverrides.rootFolder,
defaultFolder,
t,
]);
const selectedUserName = useMemo(() => {
const user = users?.find(
(u) => u.id === (requestOverrides.userId || jellyseerrUser?.id),
);
return (
user?.displayName || jellyseerrUser?.displayName || t("jellyseerr.select")
);
}, [users, requestOverrides.userId, jellyseerrUser, t]);
// Handlers
const handleProfileChange = useCallback((profileId: number) => {
setRequestOverrides((prev) => ({ ...prev, profileId }));
setActiveSelector(null);
}, []);
const handleFolderChange = useCallback((rootFolder: string) => {
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
setActiveSelector(null);
}, []);
const handleUserChange = useCallback((userId: number) => {
setRequestOverrides((prev) => ({ ...prev, userId }));
setActiveSelector(null);
}, []);
const handleTagToggle = useCallback(
(tagId: number) => {
setRequestOverrides((prev) => {
const currentTags = prev.tags || defaultTags.map((t) => t.id);
const hasTag = currentTags.includes(tagId);
return {
...prev,
tags: hasTag
? currentTags.filter((id) => id !== tagId)
: [...currentTags, tagId],
};
});
},
[defaultTags],
);
const handleRequest = useCallback(() => {
if (!modalState) return;
const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
profileId: defaultProfile?.id,
rootFolder: defaultFolder?.path,
tags: defaultTags.map((t) => t.id),
...modalState.requestBody,
...requestOverrides,
};
const seasonTitle =
modalState.requestBody?.seasons?.length === 1
? t("jellyseerr.season_number", {
season_number: modalState.requestBody.seasons[0],
})
: modalState.requestBody?.seasons &&
modalState.requestBody.seasons.length > 1
? t("jellyseerr.season_all")
: undefined;
requestMedia(
seasonTitle ? `${modalState.title}, ${seasonTitle}` : modalState.title,
body,
() => {
modalState.onRequested();
router.back();
},
);
}, [
modalState,
requestOverrides,
defaultProfile,
defaultFolder,
defaultTags,
defaultService,
defaultServiceDetails,
requestMedia,
router,
t,
]);
if (!modalState) {
return null;
}
const isDataLoaded = defaultService && defaultServiceDetails && users;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={styles.heading}>{t("jellyseerr.advanced")}</Text>
<Text style={styles.subtitle}>{modalState.title}</Text>
{isDataLoaded && isReady ? (
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
>
<View style={styles.optionsContainer}>
<TVRequestOptionRow
label={t("jellyseerr.quality_profile")}
value={selectedProfileName}
onPress={() => setActiveSelector("profile")}
hasTVPreferredFocus
/>
<TVRequestOptionRow
label={t("jellyseerr.root_folder")}
value={selectedFolderName}
onPress={() => setActiveSelector("folder")}
/>
<TVRequestOptionRow
label={t("jellyseerr.request_as")}
value={selectedUserName}
onPress={() => setActiveSelector("user")}
/>
{tagItems.length > 0 && (
<TVToggleOptionRow
label={t("jellyseerr.tags")}
items={tagItems}
onToggle={handleTagToggle}
/>
)}
</View>
</ScrollView>
) : (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>{t("common.loading")}</Text>
</View>
)}
{isReady && (
<View style={styles.buttonContainer}>
<TVButton
onPress={handleRequest}
variant='secondary'
disabled={!isDataLoaded}
>
<Ionicons
name='add'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text style={styles.buttonText}>
{t("jellyseerr.request_button")}
</Text>
</TVButton>
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
{/* Sub-selectors */}
<TVOptionSelector
visible={activeSelector === "profile"}
title={t("jellyseerr.quality_profile")}
options={qualityProfileOptions}
onSelect={handleProfileChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
<TVOptionSelector
visible={activeSelector === "folder"}
title={t("jellyseerr.root_folder")}
options={rootFolderOptions}
onSelect={handleFolderChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
cardWidth={280}
/>
<TVOptionSelector
visible={activeSelector === "user"}
title={t("jellyseerr.request_as")}
options={userOptions}
onSelect={handleUserChange}
onClose={() => setActiveSelector(null)}
cancelLabel={t("jellyseerr.cancel")}
/>
</Animated.View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
paddingHorizontal: 44,
overflow: "visible",
},
heading: {
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
},
subtitle: {
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
},
scrollView: {
maxHeight: 320,
overflow: "visible",
},
optionsContainer: {
gap: 12,
paddingVertical: 8,
paddingHorizontal: 4,
},
loadingContainer: {
height: 200,
justifyContent: "center",
alignItems: "center",
},
loadingText: {
color: "rgba(255,255,255,0.5)",
},
buttonContainer: {
marginTop: 24,
},
buttonText: {
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
},
});

View File

@@ -445,6 +445,14 @@ function Layout() {
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}

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: {

View File

@@ -0,0 +1,34 @@
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { store } from "@/utils/store";
interface ShowRequestModalParams {
requestBody: MediaRequestBody;
title: string;
id: number;
mediaType: MediaType;
onRequested: () => void;
}
export const useTVRequestModal = () => {
const router = useRouter();
const showRequestModal = useCallback(
(params: ShowRequestModalParams) => {
store.set(tvRequestModalAtom, {
requestBody: params.requestBody,
title: params.title,
id: params.id,
mediaType: params.mediaType,
onRequested: params.onRequested,
});
router.push("/(auth)/tv-request-modal");
},
[router],
);
return { showRequestModal };
};

View File

@@ -754,6 +754,8 @@
"decline": "Decline",
"requested_by": "Requested by {{user}}",
"unknown_user": "Unknown User",
"select": "Select",
"request_all": "Request All",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
"jellyseerr_test_failed": "Seerr test failed. Please try again.",

View File

@@ -709,7 +709,7 @@
"quality_profile": "Kvalitetsprofil",
"root_folder": "Rotkatalog",
"season_all": "Säsong (alla)",
"season_number": "Säsong {{seasonNumber}}",
"season_number": "Säsong {{season_number}}",
"number_episodes": "{{episode_number}} Avsnitt",
"born": "Född",
"appearances": "Framträdanden",
@@ -717,6 +717,8 @@
"decline": "Avvisa",
"requested_by": "Begärt av {{user}}",
"unknown_user": "Okänd användare",
"select": "Välj",
"request_all": "Begär alla",
"toasts": {
"jellyseer_does_not_meet_requirements": "Seerr-servern uppfyller inte minimikrav för version! Vänligen uppdatera till minst 2.0.0",
"jellyseerr_test_failed": "Seerr test misslyckades. Försök igen.",

View File

@@ -0,0 +1,13 @@
import { atom } from "jotai";
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
export type TVRequestModalState = {
requestBody: MediaRequestBody;
title: string;
id: number;
mediaType: MediaType;
onRequested: () => void;
} | null;
export const tvRequestModalAtom = atom<TVRequestModalState>(null);