fix(tv): resolve jellyseerr detail page focus navigation loop

This commit is contained in:
Fredrik Burmester
2026-01-20 22:15:00 +01:00
parent 5f44540b6f
commit e3b4952c60
6 changed files with 1710 additions and 4 deletions

View File

@@ -0,0 +1,979 @@
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useLocalSearchParams } from "expo-router";
import { orderBy } from "lodash";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Dimensions,
FlatList,
Pressable,
ScrollView,
TVFocusGuideView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { Loader } from "@/components/Loader";
import { JellyserrRatings } from "@/components/Ratings";
import { TVButton } from "@/components/tv";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useTVRequestModal } from "@/hooks/useTVRequestModal";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type Season from "@/utils/jellyseerr/server/entity/Season";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {
hasPermission,
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
// Cast card component
interface TVCastCardProps {
person: {
id: number;
name: string;
character?: string;
profilePath?: string;
};
imageProxy: (path: string, size?: string) => string;
onPress: () => void;
isFirst?: boolean;
refSetter?: (ref: View | null) => void;
}
const TVCastCard: React.FC<TVCastCardProps> = ({
person,
imageProxy,
onPress,
isFirst,
refSetter,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.08 });
const profileUrl = person.profilePath
? imageProxy(person.profilePath, "w185")
: null;
return (
<Pressable
ref={refSetter}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst}
>
<Animated.View
style={[
animatedStyle,
{
width: 140,
alignItems: "center",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<View
style={{
width: 120,
height: 120,
borderRadius: 60,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginBottom: 12,
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
}}
>
{profileUrl ? (
<Image
source={{ uri: profileUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='person' size={48} 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",
textAlign: "center",
}}
numberOfLines={2}
>
{person.name}
</Text>
{person.character && (
<Text
style={{
fontSize: 14,
color: focused
? "rgba(255,255,255,0.7)"
: "rgba(255,255,255,0.5)",
textAlign: "center",
marginTop: 4,
}}
numberOfLines={1}
>
{person.character}
</Text>
)}
</Animated.View>
</Pressable>
);
};
// Season card component
interface TVSeasonCardProps {
season: {
id: number;
seasonNumber: number;
episodeCount: number;
status: MediaStatus;
};
onPress: () => void;
canRequest: boolean;
disabled?: boolean;
onCardFocus?: () => void;
}
const TVSeasonCard: React.FC<TVSeasonCardProps> = ({
season,
onPress,
canRequest,
disabled = false,
onCardFocus,
}) => {
const { t } = useTranslation();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
const handleCardFocus = useCallback(() => {
handleFocus();
onCardFocus?.();
}, [handleFocus, onCardFocus]);
return (
<Pressable
onPress={canRequest ? onPress : undefined}
onFocus={handleCardFocus}
onBlur={handleBlur}
disabled={disabled || !canRequest}
focusable={!disabled && canRequest}
>
<Animated.View
style={[
animatedStyle,
{
minWidth: 180,
padding: 16,
backgroundColor: focused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.08)",
borderRadius: 12,
borderWidth: focused ? 2 : 1,
borderColor: focused
? "rgba(255,255,255,0.4)"
: "rgba(255,255,255,0.1)",
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.3 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
}}
>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "600",
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
}}
numberOfLines={1}
>
{t("jellyseerr.season_number", {
season_number: season.seasonNumber,
})}
</Text>
<JellyseerrStatusIcon
mediaStatus={season.status}
showRequestIcon={canRequest}
/>
</View>
<Text
style={{
fontSize: 14,
color: focused ? "rgba(255,255,255,0.8)" : "rgba(255,255,255,0.5)",
marginTop: 4,
}}
>
{t("jellyseerr.number_episodes", {
episode_number: season.episodeCount,
})}
</Text>
</Animated.View>
</Pressable>
);
};
export const TVJellyseerrPage: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
const router = useRouter();
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
mediaType: MediaType;
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const { showRequestModal } = useTVRequestModal();
const [lastActionButtonRef, setLastActionButtonRef] = useState<View | null>(
null,
);
// Scroll control ref
const mainScrollRef = useRef<ScrollView>(null);
const scrollPositionRef = useRef(0);
const {
data: details,
isFetching,
isLoading,
refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", mediaType, result.id],
staleTime: 0,
refetchOnMount: true,
queryFn: async () => {
return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!)
: jellyseerrApi?.tvDetails(result.id!);
},
});
const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details);
const canManageRequests = useMemo(() => {
if (!jellyseerrUser) return false;
return hasPermission(
Permission.MANAGE_REQUESTS,
jellyseerrUser.permissions,
);
}, [jellyseerrUser]);
const pendingRequest = useMemo(() => {
return details?.mediaInfo?.requests?.find(
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
);
}, [details]);
// Get seasons with status for TV shows
const seasons = useMemo(() => {
if (!details || mediaType !== MediaType.TV) return [];
const tvDetails = details as TvDetails;
const mediaInfoSeasons = tvDetails.mediaInfo?.seasons?.filter(
(s: Season) => s.seasonNumber !== 0,
);
const requestedSeasons =
tvDetails.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) ??
[];
return (
tvDetails.seasons?.map((season) => ({
...season,
status:
mediaInfoSeasons?.find(
(mediaSeason: Season) =>
mediaSeason.seasonNumber === season.seasonNumber,
)?.status ??
requestedSeasons?.find(
(s: Season) => s.seasonNumber === season.seasonNumber,
)?.status ??
MediaStatus.UNKNOWN,
})) ?? []
);
}, [details, mediaType]);
const allSeasonsAvailable = useMemo(
() => seasons.every((season) => season.status === MediaStatus.AVAILABLE),
[seasons],
);
// Get cast
const cast = useMemo(() => {
return details?.credits?.cast?.slice(0, 10) ?? [];
}, [details]);
// Backdrop URL
const backdropUrl = useMemo(() => {
const path = details?.backdropPath || result.backdropPath;
return path
? jellyseerrApi?.imageProxy(path, "w1920_and_h800_multi_faces")
: null;
}, [details, result.backdropPath, jellyseerrApi]);
// Poster URL
const posterUrl = useMemo(() => {
if (posterSrc) return posterSrc;
const path = details?.posterPath;
return path ? jellyseerrApi?.imageProxy(path, "w342") : null;
}, [posterSrc, details, jellyseerrApi]);
// Handlers
const handleApproveRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.approveRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_approved"));
refetch();
} catch (_error) {
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const handleDeclineRequest = useCallback(async () => {
if (!pendingRequest?.id) return;
try {
await jellyseerrApi?.declineRequest(pendingRequest.id);
toast.success(t("jellyseerr.toasts.request_declined"));
refetch();
} catch (_error) {
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const handleRequest = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId,
...(mediaType === MediaType.TV && {
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
}),
};
if (hasAdvancedRequestPermission) {
showRequestModal({
requestBody: body,
title: mediaTitle,
id: result.id!,
mediaType: mediaType!,
onRequested: refetch,
});
return;
}
requestMedia(mediaTitle, body, refetch);
}, [
details,
result,
requestMedia,
hasAdvancedRequestPermission,
mediaTitle,
refetch,
mediaType,
showRequestModal,
]);
const handleSeasonRequest = useCallback(
(seasonNumber: number) => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
mediaType: MediaType.TV,
tvdbId: details?.externalIds?.tvdbId,
seasons: [seasonNumber],
};
if (hasAdvancedRequestPermission) {
showRequestModal({
requestBody: body,
title: mediaTitle,
id: result.id!,
mediaType: MediaType.TV,
onRequested: refetch,
});
return;
}
const seasonTitle = t("jellyseerr.season_number", {
season_number: seasonNumber,
});
requestMedia(`${mediaTitle}, ${seasonTitle}`, body, refetch);
},
[
details,
result,
hasAdvancedRequestPermission,
requestMedia,
mediaTitle,
refetch,
t,
showRequestModal,
],
);
const handleRequestAll = useCallback(() => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
mediaType: MediaType.TV,
tvdbId: details?.externalIds?.tvdbId,
seasons: seasons
.filter((s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
.map((s) => s.seasonNumber),
};
if (hasAdvancedRequestPermission) {
showRequestModal({
requestBody: body,
title: mediaTitle,
id: result.id!,
mediaType: MediaType.TV,
onRequested: refetch,
});
return;
}
requestMedia(`${mediaTitle}, ${t("jellyseerr.season_all")}`, body, refetch);
}, [
details,
result,
seasons,
hasAdvancedRequestPermission,
requestMedia,
mediaTitle,
refetch,
t,
showRequestModal,
]);
// Restore scroll position when navigating within seasons section
const handleSeasonsFocus = useCallback(() => {
// Use requestAnimationFrame to restore scroll after TV focus engine scrolls
requestAnimationFrame(() => {
mainScrollRef.current?.scrollTo({
y: scrollPositionRef.current,
animated: false,
});
});
}, []);
const handlePlay = useCallback(() => {
const jellyfinMediaId = details?.mediaInfo?.jellyfinMediaId;
if (!jellyfinMediaId) return;
router.push({
pathname:
mediaType === MediaType.MOVIE
? "/(auth)/(tabs)/(search)/items/page"
: "/(auth)/(tabs)/(search)/series/[id]",
params: { id: jellyfinMediaId },
});
}, [details, mediaType, router]);
const handleCastPress = useCallback(
(personId: number) => {
router.push(`/(auth)/jellyseerr/person/${personId}` as any);
},
[router],
);
const hasJellyfinMedia = !!details?.mediaInfo?.jellyfinMediaId;
const requestedByName =
pendingRequest?.requestedBy?.displayName ||
pendingRequest?.requestedBy?.username ||
pendingRequest?.requestedBy?.jellyfinUsername ||
t("jellyseerr.unknown_user");
if (isLoading || isFetching) {
return (
<View
style={{
flex: 1,
backgroundColor: "#000",
justifyContent: "center",
alignItems: "center",
}}
>
<Loader />
</View>
);
}
return (
<View style={{ flex: 1, backgroundColor: "#000" }}>
{/* Full-screen backdrop */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{backdropUrl ? (
<Image
source={{ uri: backdropUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
transition={300}
/>
) : (
<View style={{ flex: 1, backgroundColor: "#1a1a1a" }} />
)}
{/* Bottom gradient */}
<LinearGradient
colors={["transparent", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "70%",
}}
/>
{/* Left gradient */}
<LinearGradient
colors={["rgba(0,0,0,0.8)", "transparent"]}
start={{ x: 0, y: 0 }}
end={{ x: 0.6, y: 0 }}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: "60%",
}}
/>
</View>
{/* Main content */}
<ScrollView
ref={mainScrollRef}
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 140,
paddingBottom: insets.bottom + 60,
paddingHorizontal: insets.left + 80,
}}
showsVerticalScrollIndicator={false}
scrollEventThrottle={16}
onScroll={(e) => {
scrollPositionRef.current = e.nativeEvent.contentOffset.y;
}}
>
{/* Top section - Poster + Content */}
<View
style={{
flexDirection: "row",
minHeight: SCREEN_HEIGHT * 0.45,
}}
>
{/* Left side - Poster */}
<View
style={{
width: SCREEN_WIDTH * 0.22,
marginRight: 50,
}}
>
<View
style={{
aspectRatio: 2 / 3,
borderRadius: 16,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.5,
shadowRadius: 20,
}}
>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
cachePolicy='memory-disk'
/>
) : (
<View
style={{
flex: 1,
backgroundColor: "rgba(255,255,255,0.1)",
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='image-outline'
size={48}
color='rgba(255,255,255,0.3)'
/>
</View>
)}
</View>
</View>
{/* Right side - Content */}
<View style={{ flex: 1, justifyContent: "center" }}>
{/* Ratings */}
{details && (
<JellyserrRatings
result={
details as MovieDetails | TvDetails | MovieResult | TvResult
}
/>
)}
{/* Title */}
<Text
style={{
fontSize: TVTypography.display,
fontWeight: "bold",
color: "#FFFFFF",
marginTop: 8,
marginBottom: 12,
}}
numberOfLines={2}
>
{mediaTitle}
</Text>
{/* Year */}
<Text
style={{
fontSize: TVTypography.body,
color: "rgba(255,255,255,0.7)",
marginBottom: 16,
}}
>
{releaseYear}
</Text>
{/* Genres */}
{details?.genres && details.genres.length > 0 && (
<View style={{ marginBottom: 24 }}>
<GenreTags genres={details.genres.map((g) => g.name)} />
</View>
)}
{/* Overview */}
{(details?.overview || result.overview) && (
<BlurView
intensity={10}
tint='light'
style={{
borderRadius: 8,
overflow: "hidden",
maxWidth: SCREEN_WIDTH * 0.45,
marginBottom: 32,
}}
>
<View
style={{
padding: 16,
backgroundColor: "rgba(0,0,0,0.3)",
}}
>
<Text
style={{
fontSize: TVTypography.body,
color: "#E5E7EB",
lineHeight: 32,
}}
numberOfLines={4}
>
{details?.overview || result.overview}
</Text>
</View>
</BlurView>
)}
{/* Action buttons */}
<View
style={{
flexDirection: "row",
gap: 16,
marginBottom: 24,
}}
>
{hasJellyfinMedia && (
<TVButton
onPress={handlePlay}
hasTVPreferredFocus
variant='primary'
refSetter={!canRequest ? setLastActionButtonRef : undefined}
>
<Ionicons
name='play'
size={28}
color='#000000'
style={{ marginRight: 10 }}
/>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#000000",
}}
>
{t("common.play")}
</Text>
</TVButton>
)}
{canRequest && (
<TVButton
onPress={handleRequest}
variant='secondary'
hasTVPreferredFocus={!hasJellyfinMedia}
refSetter={setLastActionButtonRef}
>
<Ionicons
name='add'
size={24}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_button")}
</Text>
</TVButton>
)}
</View>
{/* Approve/Decline for managers */}
{canManageRequests && pendingRequest && (
<View style={{ marginBottom: 24 }}>
<View
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: 12,
}}
>
<Ionicons name='person-outline' size={18} color='#9CA3AF' />
<Text
style={{
fontSize: TVTypography.callout,
color: "#9CA3AF",
marginLeft: 8,
}}
>
{t("jellyseerr.requested_by", { user: requestedByName })}
</Text>
</View>
<View style={{ flexDirection: "row", gap: 16 }}>
<TVButton onPress={handleApproveRequest} variant='secondary'>
<Ionicons
name='checkmark'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.approve")}
</Text>
</TVButton>
<TVButton onPress={handleDeclineRequest} variant='secondary'>
<Ionicons
name='close'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.decline")}
</Text>
</TVButton>
</View>
</View>
)}
</View>
</View>
{/* Seasons section (TV shows only) */}
{mediaType === MediaType.TV &&
seasons.filter((s) => s.seasonNumber !== 0).length > 0 && (
<View style={{ marginTop: 40, marginBottom: 32 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
}}
>
{t("item_card.seasons")}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ paddingVertical: 16, gap: 16 }}
>
{!allSeasonsAvailable && (
<TVButton onPress={handleRequestAll} variant='secondary'>
<Ionicons
name='bag-add'
size={20}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "600",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_all")}
</Text>
</TVButton>
)}
{orderBy(
seasons.filter((s) => s.seasonNumber !== 0),
"seasonNumber",
"desc",
).map((season) => {
const canRequestSeason =
season.status === MediaStatus.UNKNOWN;
return (
<TVSeasonCard
key={season.id}
season={season}
onPress={() => handleSeasonRequest(season.seasonNumber)}
canRequest={canRequestSeason}
onCardFocus={handleSeasonsFocus}
/>
);
})}
</ScrollView>
</View>
)}
{/* Cast section */}
{cast.length > 0 && jellyseerrApi && (
<View style={{ marginTop: 24 }}>
<Text
style={{
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 16,
}}
>
{t("jellyseerr.cast")}
</Text>
{/* Focus guide for upward navigation from cast to action buttons */}
{lastActionButtonRef && (
<TVFocusGuideView
destinations={[lastActionButtonRef]}
style={{ height: 1, width: "100%" }}
/>
)}
<FlatList
horizontal
data={cast}
keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingVertical: 16,
gap: 28,
}}
style={{ overflow: "visible" }}
renderItem={({ item, index }) => (
<TVCastCard
person={item}
imageProxy={(path, size) =>
jellyseerrApi.imageProxy(path, size || "w185")
}
onPress={() => handleCastPress(item.id)}
isFirst={index === 0}
/>
)}
/>
</View>
)}
</ScrollView>
</View>
);
};

View File

@@ -0,0 +1,518 @@
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
BackHandler,
Easing,
ScrollView,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVButton, TVOptionSelector } from "@/components/tv";
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
import { TVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import type {
QualityProfile,
RootFolder,
Tag,
} from "@/utils/jellyseerr/server/api/servarr/base";
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { TVRequestOptionRow } from "./TVRequestOptionRow";
import { TVToggleOptionRow } from "./TVToggleOptionRow";
interface TVRequestModalProps {
visible: boolean;
requestBody?: MediaRequestBody;
title: string;
id: number;
mediaType: MediaType;
onClose: () => void;
onRequested: () => void;
}
export const TVRequestModal: React.FC<TVRequestModalProps> = ({
visible,
requestBody,
title,
id,
mediaType,
onClose,
onRequested,
}) => {
const { t } = useTranslation();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
mediaId: Number(id),
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;
useEffect(() => {
if (visible) {
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();
}
}, [visible, overlayOpacity, sheetTranslateY]);
// Handle back button to close modal
useEffect(() => {
if (!visible) return;
const handleBackPress = () => {
// If a sub-selector is open, close it first
if (activeSelector) {
setActiveSelector(null);
} else {
onClose();
}
return true; // Prevent default back behavior
};
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
handleBackPress,
);
return () => subscription.remove();
}, [visible, activeSelector, onClose]);
const { data: serviceSettings } = useQuery({
queryKey: ["jellyseerr", "request", mediaType, "service"],
queryFn: async () =>
jellyseerrApi?.service(mediaType === "movie" ? "radarr" : "sonarr"),
enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
});
const { data: users } = useQuery({
queryKey: ["jellyseerr", "users"],
queryFn: async () =>
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
enabled: !!jellyseerrApi && !!jellyseerrUser && visible,
});
const defaultService = useMemo(
() => serviceSettings?.find?.((v) => v.isDefault),
[serviceSettings],
);
const { data: defaultServiceDetails } = useQuery({
queryKey: [
"jellyseerr",
"request",
mediaType,
"service",
"details",
defaultService?.id,
],
queryFn: async () => {
setRequestOverrides((prev) => ({
...prev,
serverId: defaultService?.id,
}));
return jellyseerrApi?.serviceDetails(
mediaType === "movie" ? "radarr" : "sonarr",
defaultService!.id,
);
},
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService && visible,
});
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(() => {
const body = {
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
profileId: defaultProfile?.id,
rootFolder: defaultFolder?.path,
tags: defaultTags.map((t) => t.id),
...requestBody,
...requestOverrides,
};
const seasonTitle =
requestBody?.seasons?.length === 1
? t("jellyseerr.season_number", {
season_number: requestBody.seasons[0],
})
: requestBody?.seasons && requestBody.seasons.length > 1
? t("jellyseerr.season_all")
: undefined;
requestMedia(
seasonTitle ? `${title}, ${seasonTitle}` : title,
body,
onRequested,
);
}, [
requestBody,
requestOverrides,
defaultProfile,
defaultFolder,
defaultTags,
defaultService,
defaultServiceDetails,
title,
requestMedia,
onRequested,
t,
]);
if (!visible) return null;
const isDataLoaded = defaultService && defaultServiceDetails && users;
return (
<>
<Animated.View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "flex-end",
zIndex: 1000,
opacity: overlayOpacity,
}}
>
<Animated.View
style={{
width: "100%",
transform: [{ translateY: sheetTranslateY }],
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
paddingTop: 24,
paddingBottom: 50,
paddingHorizontal: 44,
overflow: "visible",
}}
>
<Text
style={{
fontSize: TVTypography.heading,
fontWeight: "bold",
color: "#FFFFFF",
marginBottom: 8,
}}
>
{t("jellyseerr.advanced")}
</Text>
<Text
style={{
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 24,
}}
>
{title}
</Text>
{isDataLoaded ? (
<ScrollView
style={{ maxHeight: 320, overflow: "visible" }}
showsVerticalScrollIndicator={false}
>
<View
style={{
gap: 12,
paddingVertical: 8,
paddingHorizontal: 4,
}}
>
<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={{
height: 200,
justifyContent: "center",
alignItems: "center",
}}
>
<Text style={{ color: "rgba(255,255,255,0.5)" }}>
{t("common.loading")}
</Text>
</View>
)}
<View style={{ marginTop: 24 }}>
<TVButton
onPress={handleRequest}
variant='secondary'
disabled={!isDataLoaded}
>
<Ionicons
name='add'
size={22}
color='#FFFFFF'
style={{ marginRight: 8 }}
/>
<Text
style={{
fontSize: TVTypography.callout,
fontWeight: "bold",
color: "#FFFFFF",
}}
>
{t("jellyseerr.request_button")}
</Text>
</TVButton>
</View>
</TVFocusGuideView>
</BlurView>
</Animated.View>
</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")}
/>
</>
);
};

View File

@@ -0,0 +1,85 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
interface TVRequestOptionRowProps {
label: string;
value: string;
onPress: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}
export const TVRequestOptionRow: React.FC<TVRequestOptionRowProps> = ({
label,
value,
onPress,
hasTVPreferredFocus = false,
disabled = false,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.02,
});
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 14,
paddingHorizontal: 16,
backgroundColor: focused
? "rgba(255,255,255,0.15)"
: "rgba(255,255,255,0.05)",
borderRadius: 10,
borderWidth: 1,
borderColor: focused
? "rgba(255,255,255,0.3)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Text
style={{
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.6)",
}}
>
{label}
</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Text
style={{
fontSize: TVTypography.body,
color: focused ? "#FFFFFF" : "rgba(255,255,255,0.9)",
fontWeight: "500",
}}
numberOfLines={1}
>
{value}
</Text>
<Ionicons
name='chevron-forward'
size={18}
color={focused ? "#FFFFFF" : "rgba(255,255,255,0.5)"}
/>
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,115 @@
import React from "react";
import { Animated, Pressable, ScrollView, View } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { TVTypography } from "@/constants/TVTypography";
interface ToggleItem {
id: number;
label: string;
selected: boolean;
}
interface TVToggleChipProps {
item: ToggleItem;
onToggle: (id: number) => void;
disabled?: boolean;
}
const TVToggleChip: React.FC<TVToggleChipProps> = ({
item,
onToggle,
disabled = false,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.08,
});
return (
<Pressable
onPress={() => onToggle(item.id)}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
paddingVertical: 10,
paddingHorizontal: 16,
backgroundColor: focused
? "#fff"
: item.selected
? "rgba(255,255,255,0.25)"
: "rgba(255,255,255,0.1)",
borderRadius: 20,
borderWidth: 1,
borderColor: focused
? "#fff"
: item.selected
? "rgba(255,255,255,0.4)"
: "rgba(255,255,255,0.15)",
},
]}
>
<Text
style={{
fontSize: TVTypography.callout,
color: focused ? "#000" : "#fff",
fontWeight: item.selected || focused ? "600" : "400",
}}
>
{item.label}
</Text>
</Animated.View>
</Pressable>
);
};
interface TVToggleOptionRowProps {
label: string;
items: ToggleItem[];
onToggle: (id: number) => void;
disabled?: boolean;
}
export const TVToggleOptionRow: React.FC<TVToggleOptionRowProps> = ({
label,
items,
onToggle,
disabled = false,
}) => {
if (items.length === 0) return null;
return (
<View style={{ marginBottom: 16 }}>
<Text
style={{
fontSize: TVTypography.callout,
color: "rgba(255,255,255,0.6)",
marginBottom: 10,
}}
>
{label}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{ gap: 10, paddingVertical: 12 }}
>
{items.map((item) => (
<TVToggleChip
key={item.id}
item={item}
onToggle={onToggle}
disabled={disabled}
/>
))}
</ScrollView>
</View>
);
};

View File

@@ -0,0 +1,4 @@
export { TVJellyseerrPage } from "./TVJellyseerrPage";
export { TVRequestModal } from "./TVRequestModal";
export { TVRequestOptionRow } from "./TVRequestOptionRow";
export { TVToggleOptionRow } from "./TVToggleOptionRow";

View File

@@ -11,6 +11,7 @@ export interface TVButtonProps {
style?: ViewStyle;
scaleAmount?: number;
square?: boolean;
refSetter?: (ref: View | null) => void;
}
const getButtonStyles = (
@@ -31,10 +32,12 @@ const getButtonStyles = (
};
case "secondary":
return {
backgroundColor: focused ? "#7c3aed" : "rgba(124, 58, 237, 0.8)",
shadowColor: "#a855f7",
borderWidth: 1,
borderColor: "transparent",
backgroundColor: focused
? "rgba(255, 255, 255, 0.3)"
: "rgba(255, 255, 255, 0.15)",
shadowColor: "#fff",
borderWidth: 2,
borderColor: focused ? "#fff" : "rgba(255, 255, 255, 0.2)",
};
default:
return {
@@ -55,6 +58,7 @@ export const TVButton: React.FC<TVButtonProps> = ({
style,
scaleAmount = 1.05,
square = false,
refSetter,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount });
@@ -63,6 +67,7 @@ export const TVButton: React.FC<TVButtonProps> = ({
return (
<Pressable
ref={refSetter}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}