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 React, { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Animated, Dimensions, 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 { Loader } from "@/components/Loader"; import { JellyserrRatings } from "@/components/Ratings"; import { TVButton } from "@/components/tv"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useTVRequestModal } from "@/hooks/useTVRequestModal"; import { useTVSeasonSelectModal } from "@/hooks/useTVSeasonSelectModal"; 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; refSetter?: (ref: View | null) => void; } const TVCastCard: React.FC = ({ person, imageProxy, onPress, refSetter, }) => { const typography = useScaledTVTypography(); 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} )} ); }; export const TVJellyseerrPage: React.FC = () => { const typography = useScaledTVTypography(); 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 { showSeasonSelectModal } = useTVSeasonSelectModal(); // Refs for TVFocusGuideView destinations (useState triggers re-render when set) const [playButtonRef, setPlayButtonRef] = useState(null); const [firstCastCardRef, setFirstCastCardRef] = useState(null); 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], ); // Check if there are any requestable seasons (status === UNKNOWN) const hasRequestableSeasons = useMemo( () => seasons.some( (season) => season.seasonNumber !== 0 && season.status === MediaStatus.UNKNOWN, ), [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 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, ]); const handleOpenSeasonSelectModal = useCallback(() => { showSeasonSelectModal({ seasons: seasons.filter((s) => s.seasonNumber !== 0), title: mediaTitle, mediaId: Number(result.id!), tvdbId: details?.externalIds?.tvdbId, hasAdvancedRequestPermission, onRequested: refetch, }); }, [ seasons, mediaTitle, result, details, hasAdvancedRequestPermission, refetch, showSeasonSelectModal, ]); 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 */} {/* 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")} )} {/* Request button - only show for movies, TV series use Request All + season cards */} {canRequest && mediaType === MediaType.MOVIE && ( {t("jellyseerr.request_button")} )} {/* Request All button for TV series */} {mediaType === MediaType.TV && seasons.filter((s) => s.seasonNumber !== 0).length > 0 && hasRequestableSeasons && ( {t("jellyseerr.request_all")} )} {/* Request Seasons button for TV series */} {mediaType === MediaType.TV && seasons.filter((s) => s.seasonNumber !== 0).length > 0 && hasRequestableSeasons && ( {t("jellyseerr.request_seasons")} )} {/* Approve/Decline for managers */} {canManageRequests && pendingRequest && ( {t("jellyseerr.requested_by", { user: requestedByName })} {t("jellyseerr.approve")} {t("jellyseerr.decline")} )} {/* Cast section */} {cast.length > 0 && jellyseerrApi && ( {t("jellyseerr.cast")} {/* Focus guides for bidirectional navigation - stacked together */} {/* Downward: action buttons → first cast card */} {firstCastCardRef && ( )} {/* Upward: cast → action buttons */} {playButtonRef && ( )} {cast.map((person, index) => ( jellyseerrApi.imageProxy(path, size || "w185") } onPress={() => handleCastPress(person.id)} refSetter={index === 0 ? setFirstCastCardRef : undefined} /> ))} )} ); };