From e3b4952c6093263eb6852b898b1fbed7c54b52b1 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 20 Jan 2026 22:15:00 +0100 Subject: [PATCH] fix(tv): resolve jellyseerr detail page focus navigation loop --- components/jellyseerr/tv/TVJellyseerrPage.tsx | 979 ++++++++++++++++++ components/jellyseerr/tv/TVRequestModal.tsx | 518 +++++++++ .../jellyseerr/tv/TVRequestOptionRow.tsx | 85 ++ .../jellyseerr/tv/TVToggleOptionRow.tsx | 115 ++ components/jellyseerr/tv/index.ts | 4 + components/tv/TVButton.tsx | 13 +- 6 files changed, 1710 insertions(+), 4 deletions(-) create mode 100644 components/jellyseerr/tv/TVJellyseerrPage.tsx create mode 100644 components/jellyseerr/tv/TVRequestModal.tsx create mode 100644 components/jellyseerr/tv/TVRequestOptionRow.tsx create mode 100644 components/jellyseerr/tv/TVToggleOptionRow.tsx create mode 100644 components/jellyseerr/tv/index.ts diff --git a/components/jellyseerr/tv/TVJellyseerrPage.tsx b/components/jellyseerr/tv/TVJellyseerrPage.tsx new file mode 100644 index 00000000..f3660972 --- /dev/null +++ b/components/jellyseerr/tv/TVJellyseerrPage.tsx @@ -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 = ({ + person, + imageProxy, + onPress, + isFirst, + refSetter, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.08 }); + + const profileUrl = person.profilePath + ? imageProxy(person.profilePath, "w185") + : null; + + return ( + + + + {profileUrl ? ( + + ) : ( + + + + )} + + + {person.name} + + {person.character && ( + + {person.character} + + )} + + + ); +}; + +// 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 = ({ + 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 ( + + + + + {t("jellyseerr.season_number", { + season_number: season.seasonNumber, + })} + + + + + {t("jellyseerr.number_episodes", { + episode_number: season.episodeCount, + })} + + + + ); +}; + +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; + + const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); + const { showRequestModal } = useTVRequestModal(); + const [lastActionButtonRef, setLastActionButtonRef] = useState( + null, + ); + + // Scroll control ref + const mainScrollRef = useRef(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 ( + + + + ); + } + + return ( + + {/* Full-screen backdrop */} + + {backdropUrl ? ( + + ) : ( + + )} + {/* Bottom gradient */} + + {/* Left gradient */} + + + + {/* Main content */} + { + scrollPositionRef.current = e.nativeEvent.contentOffset.y; + }} + > + {/* Top section - Poster + Content */} + + {/* Left side - Poster */} + + + {posterUrl ? ( + + ) : ( + + + + )} + + + + {/* Right side - Content */} + + {/* Ratings */} + {details && ( + + )} + + {/* Title */} + + {mediaTitle} + + + {/* Year */} + + {releaseYear} + + + {/* Genres */} + {details?.genres && details.genres.length > 0 && ( + + g.name)} /> + + )} + + {/* Overview */} + {(details?.overview || result.overview) && ( + + + + {details?.overview || result.overview} + + + + )} + + {/* Action buttons */} + + {hasJellyfinMedia && ( + + + + {t("common.play")} + + + )} + + {canRequest && ( + + + + {t("jellyseerr.request_button")} + + + )} + + + {/* Approve/Decline for managers */} + {canManageRequests && pendingRequest && ( + + + + + {t("jellyseerr.requested_by", { user: requestedByName })} + + + + + + + + {t("jellyseerr.approve")} + + + + + + + {t("jellyseerr.decline")} + + + + + )} + + + + {/* Seasons section (TV shows only) */} + {mediaType === MediaType.TV && + seasons.filter((s) => s.seasonNumber !== 0).length > 0 && ( + + + {t("item_card.seasons")} + + + + {!allSeasonsAvailable && ( + + + + {t("jellyseerr.request_all")} + + + )} + {orderBy( + seasons.filter((s) => s.seasonNumber !== 0), + "seasonNumber", + "desc", + ).map((season) => { + const canRequestSeason = + season.status === MediaStatus.UNKNOWN; + return ( + handleSeasonRequest(season.seasonNumber)} + canRequest={canRequestSeason} + onCardFocus={handleSeasonsFocus} + /> + ); + })} + + + )} + + {/* Cast section */} + {cast.length > 0 && jellyseerrApi && ( + + + {t("jellyseerr.cast")} + + + {/* Focus guide for upward navigation from cast to action buttons */} + {lastActionButtonRef && ( + + )} + + item.id.toString()} + showsHorizontalScrollIndicator={false} + contentContainerStyle={{ + paddingVertical: 16, + gap: 28, + }} + style={{ overflow: "visible" }} + renderItem={({ item, index }) => ( + + jellyseerrApi.imageProxy(path, size || "w185") + } + onPress={() => handleCastPress(item.id)} + isFirst={index === 0} + /> + )} + /> + + )} + + + ); +}; diff --git a/components/jellyseerr/tv/TVRequestModal.tsx b/components/jellyseerr/tv/TVRequestModal.tsx new file mode 100644 index 00000000..b9bfe9b8 --- /dev/null +++ b/components/jellyseerr/tv/TVRequestModal.tsx @@ -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 = ({ + visible, + requestBody, + title, + id, + mediaType, + onClose, + onRequested, +}) => { + const { t } = useTranslation(); + const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); + + const [requestOverrides, setRequestOverrides] = useState({ + 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[] = 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[] = 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[] = 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 ( + <> + + + + + + {t("jellyseerr.advanced")} + + + {title} + + + {isDataLoaded ? ( + + + setActiveSelector("profile")} + hasTVPreferredFocus + /> + setActiveSelector("folder")} + /> + setActiveSelector("user")} + /> + + {tagItems.length > 0 && ( + + )} + + + ) : ( + + + {t("common.loading")} + + + )} + + + + + + {t("jellyseerr.request_button")} + + + + + + + + + {/* Sub-selectors */} + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + /> + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + cardWidth={280} + /> + setActiveSelector(null)} + cancelLabel={t("jellyseerr.cancel")} + /> + + ); +}; diff --git a/components/jellyseerr/tv/TVRequestOptionRow.tsx b/components/jellyseerr/tv/TVRequestOptionRow.tsx new file mode 100644 index 00000000..60748e40 --- /dev/null +++ b/components/jellyseerr/tv/TVRequestOptionRow.tsx @@ -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 = ({ + label, + value, + onPress, + hasTVPreferredFocus = false, + disabled = false, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.02, + }); + + return ( + + + + {label} + + + + {value} + + + + + + ); +}; diff --git a/components/jellyseerr/tv/TVToggleOptionRow.tsx b/components/jellyseerr/tv/TVToggleOptionRow.tsx new file mode 100644 index 00000000..46be5f60 --- /dev/null +++ b/components/jellyseerr/tv/TVToggleOptionRow.tsx @@ -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 = ({ + item, + onToggle, + disabled = false, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ + scaleAmount: 1.08, + }); + + return ( + onToggle(item.id)} + onFocus={handleFocus} + onBlur={handleBlur} + disabled={disabled} + focusable={!disabled} + > + + + {item.label} + + + + ); +}; + +interface TVToggleOptionRowProps { + label: string; + items: ToggleItem[]; + onToggle: (id: number) => void; + disabled?: boolean; +} + +export const TVToggleOptionRow: React.FC = ({ + label, + items, + onToggle, + disabled = false, +}) => { + if (items.length === 0) return null; + + return ( + + + {label} + + + {items.map((item) => ( + + ))} + + + ); +}; diff --git a/components/jellyseerr/tv/index.ts b/components/jellyseerr/tv/index.ts new file mode 100644 index 00000000..b20700c4 --- /dev/null +++ b/components/jellyseerr/tv/index.ts @@ -0,0 +1,4 @@ +export { TVJellyseerrPage } from "./TVJellyseerrPage"; +export { TVRequestModal } from "./TVRequestModal"; +export { TVRequestOptionRow } from "./TVRequestOptionRow"; +export { TVToggleOptionRow } from "./TVToggleOptionRow"; diff --git a/components/tv/TVButton.tsx b/components/tv/TVButton.tsx index dde95e11..791b0aa1 100644 --- a/components/tv/TVButton.tsx +++ b/components/tv/TVButton.tsx @@ -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 = ({ style, scaleAmount = 1.05, square = false, + refSetter, }) => { const { focused, handleFocus, handleBlur, animatedStyle } = useTVFocusAnimation({ scaleAmount }); @@ -63,6 +67,7 @@ export const TVButton: React.FC = ({ return (