import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; import { useSegments } from "expo-router"; import { useAtom } from "jotai"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { Animated, Dimensions, Easing, FlatList, Pressable, ScrollView, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { ItemCardText } from "@/components/ItemCardText"; import { Loader } from "@/components/Loader"; import MoviePoster, { TV_POSTER_WIDTH, } from "@/components/posters/MoviePoster.tv"; import useRouter from "@/hooks/useAppRouter"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; const { width: SCREEN_WIDTH } = Dimensions.get("window"); const HORIZONTAL_PADDING = 80; const TOP_PADDING = 140; const ACTOR_IMAGE_SIZE = 250; const ITEM_GAP = 16; const SCALE_PADDING = 20; // Focusable poster wrapper component for TV const TVFocusablePoster: React.FC<{ children: React.ReactNode; onPress: () => void; hasTVPreferredFocus?: boolean; onFocus?: () => void; onBlur?: () => void; }> = ({ children, onPress, hasTVPreferredFocus, onFocus, onBlur }) => { const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; const animateTo = (value: number) => Animated.timing(scale, { toValue: value, duration: 150, easing: Easing.out(Easing.quad), useNativeDriver: true, }).start(); return ( { setFocused(true); animateTo(1.05); onFocus?.(); }} onBlur={() => { setFocused(false); animateTo(1); onBlur?.(); }} hasTVPreferredFocus={hasTVPreferredFocus} > {children} ); }; interface TVActorPageProps { personId: string; } export const TVActorPage: React.FC = ({ personId }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); const router = useRouter(); const segments = useSegments(); const from = (segments as string[])[2] || "(home)"; const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); // Track which filmography item is currently focused for dynamic backdrop const [focusedItem, setFocusedItem] = useState(null); // Fetch actor details const { data: item, isLoading: isLoadingActor } = useQuery({ queryKey: ["item", personId], queryFn: async () => await getUserItemData({ api, userId: user?.Id, itemId: personId, }), enabled: !!personId && !!api, staleTime: 60, }); // Fetch movies const { data: movies = [], isLoading: isLoadingMovies } = useQuery({ queryKey: ["actor", "movies", personId], queryFn: async () => { if (!api || !user?.Id) return []; const response = await getItemsApi(api).getItems({ userId: user.Id, personIds: [personId], startIndex: 0, limit: 20, sortOrder: ["Descending", "Descending", "Ascending"], includeItemTypes: ["Movie"], recursive: true, fields: ["ParentId", "PrimaryImageAspectRatio"], sortBy: ["PremiereDate", "ProductionYear", "SortName"], collapseBoxSetItems: false, }); return response.data.Items || []; }, enabled: !!personId && !!api && !!user?.Id, staleTime: 60, }); // Fetch series const { data: series = [], isLoading: isLoadingSeries } = useQuery({ queryKey: ["actor", "series", personId], queryFn: async () => { if (!api || !user?.Id) return []; const response = await getItemsApi(api).getItems({ userId: user.Id, personIds: [personId], startIndex: 0, limit: 20, sortOrder: ["Descending", "Descending", "Ascending"], includeItemTypes: ["Series"], recursive: true, fields: ["ParentId", "PrimaryImageAspectRatio"], sortBy: ["PremiereDate", "ProductionYear", "SortName"], collapseBoxSetItems: false, }); return response.data.Items || []; }, enabled: !!personId && !!api && !!user?.Id, staleTime: 60, }); // Get backdrop URL from the currently focused filmography item // Changes dynamically as user navigates through the list const backdropUrl = useMemo(() => { // Use focused item if available, otherwise fall back to first movie or series const itemForBackdrop = focusedItem ?? movies[0] ?? series[0]; if (!itemForBackdrop) return null; return getBackdropUrl({ api, item: itemForBackdrop, quality: 90, width: 1920, }); }, [api, focusedItem, movies, series]); // Crossfade animation for backdrop transitions // Use two alternating layers for smooth crossfade const [activeLayer, setActiveLayer] = useState<0 | 1>(0); const [layer0Url, setLayer0Url] = useState(null); const [layer1Url, setLayer1Url] = useState(null); const layer0Opacity = useRef(new Animated.Value(1)).current; const layer1Opacity = useRef(new Animated.Value(0)).current; useEffect(() => { if (!backdropUrl) return; let isCancelled = false; const performCrossfade = async () => { // Prefetch the image before starting the crossfade try { await Image.prefetch(backdropUrl); } catch { // Continue even if prefetch fails } if (isCancelled) return; // Determine which layer to fade in const incomingLayer = activeLayer === 0 ? 1 : 0; const incomingOpacity = incomingLayer === 0 ? layer0Opacity : layer1Opacity; const outgoingOpacity = incomingLayer === 0 ? layer1Opacity : layer0Opacity; // Set the new URL on the incoming layer if (incomingLayer === 0) { setLayer0Url(backdropUrl); } else { setLayer1Url(backdropUrl); } // Small delay to ensure image component has the new URL await new Promise((resolve) => setTimeout(resolve, 50)); if (isCancelled) return; // Crossfade: fade in the incoming layer, fade out the outgoing Animated.parallel([ Animated.timing(incomingOpacity, { toValue: 1, duration: 500, easing: Easing.inOut(Easing.quad), useNativeDriver: true, }), Animated.timing(outgoingOpacity, { toValue: 0, duration: 500, easing: Easing.inOut(Easing.quad), useNativeDriver: true, }), ]).start(() => { if (!isCancelled) { // After animation completes, switch the active layer setActiveLayer(incomingLayer); } }); }; performCrossfade(); return () => { isCancelled = true; }; }, [backdropUrl]); // Get actor image URL const actorImageUrl = useMemo(() => { if (!item?.Id || !api?.basePath) return null; return `${api.basePath}/Items/${item.Id}/Images/Primary?fillWidth=${ACTOR_IMAGE_SIZE * 2}&fillHeight=${ACTOR_IMAGE_SIZE * 2}&quality=90`; }, [api?.basePath, item?.Id]); // Handle filmography item press const handleItemPress = useCallback( (filmItem: BaseItemDto) => { const navigation = getItemNavigation(filmItem, from); router.push(navigation as any); }, [from, router], ); // List item layout const getItemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ length: TV_POSTER_WIDTH + ITEM_GAP, offset: (TV_POSTER_WIDTH + ITEM_GAP) * index, index, }), [], ); // Render filmography item const renderFilmographyItem = useCallback( ( { item: filmItem, index }: { item: BaseItemDto; index: number }, isFirstSection: boolean, ) => ( handleItemPress(filmItem)} onFocus={() => setFocusedItem(filmItem)} hasTVPreferredFocus={isFirstSection && index === 0} > ), [handleItemPress], ); if (isLoadingActor) { return ( ); } if (!item?.Id) return null; return ( {/* Full-screen backdrop with crossfade - two alternating layers */} {/* Layer 0 */} {layer0Url ? ( ) : ( )} {/* Layer 1 */} {layer1Url ? ( ) : ( )} {/* Gradient overlays for readability */} {/* Main content area */} {/* Top section - Actor image + Info */} {/* Left side - Circular actor image */} {actorImageUrl ? ( ) : ( )} {/* Right side - Info */} {/* Actor name */} {item.Name} {/* Production year / Birth year */} {item.ProductionYear && ( {item.ProductionYear} )} {/* Biography */} {item.Overview && ( {item.Overview} )} {/* Filmography sections */} {/* Movies Section */} {isLoadingMovies ? ( ) : ( movies.length > 0 && ( {t("item_card.movies")} filmItem.Id!} renderItem={(props) => renderFilmographyItem(props, true)} showsHorizontalScrollIndicator={false} initialNumToRender={6} maxToRenderPerBatch={4} windowSize={5} removeClippedSubviews={false} getItemLayout={getItemLayout} style={{ overflow: "visible" }} contentContainerStyle={{ paddingVertical: SCALE_PADDING, paddingHorizontal: SCALE_PADDING, }} /> ) )} {/* Series Section */} {isLoadingSeries ? ( ) : ( series.length > 0 && ( {t("item_card.shows")} filmItem.Id!} renderItem={(props) => renderFilmographyItem(props, movies.length === 0) } showsHorizontalScrollIndicator={false} initialNumToRender={6} maxToRenderPerBatch={4} windowSize={5} removeClippedSubviews={false} getItemLayout={getItemLayout} style={{ overflow: "visible" }} contentContainerStyle={{ paddingVertical: SCALE_PADDING, paddingHorizontal: SCALE_PADDING, }} /> ) )} {/* Empty state - only show if both sections are empty and not loading */} {!isLoadingMovies && !isLoadingSeries && movies.length === 0 && series.length === 0 && ( {t("common.no_results")} )} ); };