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 { useScaledTVTypography } 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 typography = useScaledTVTypography(); 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")} /> ); };