diff --git a/app.json b/app.json index af6cc4b1..0c7690ee 100644 --- a/app.json +++ b/app.json @@ -67,6 +67,9 @@ "topShelf2x": "./assets/images/icon-tvos-topshelf-2x.png", "topShelfWide": "./assets/images/icon-tvos-topshelf-wide.png", "topShelfWide2x": "./assets/images/icon-tvos-topshelf-wide-2x.png" + }, + "infoPlist": { + "UIAppSupportsHDR": true } } ], diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx index f2f8dcaf..a6dde1e0 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/persons/[personId].tsx @@ -6,7 +6,7 @@ import { useLocalSearchParams } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { View } from "react-native"; +import { Platform, View } from "react-native"; import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorizontalScroll"; import { Text } from "@/components/common/Text"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; @@ -15,6 +15,7 @@ import { Loader } from "@/components/Loader"; import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { TVActorPage } from "@/components/persons/TVActorPage"; import MoviePoster from "@/components/posters/MoviePoster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; @@ -23,6 +24,16 @@ import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; const page: React.FC = () => { const local = useLocalSearchParams(); const { personId } = local as { personId: string }; + + // Render TV-optimized page on TV platforms + if (Platform.isTV) { + return ; + } + + return ; +}; + +const MobileActorPage: React.FC<{ personId: string }> = ({ personId }) => { const { t } = useTranslation(); const [api] = useAtom(apiAtom); diff --git a/components/persons/TVActorPage.tsx b/components/persons/TVActorPage.tsx new file mode 100644 index 00000000..879106c9 --- /dev/null +++ b/components/persons/TVActorPage.tsx @@ -0,0 +1,530 @@ +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, + 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 [focusedIndex, setFocusedIndex] = useState(0); + + // FlatList ref for scrolling back + const filmographyListRef = useRef>(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 filmography + const { data: filmography = [], isLoading: isLoadingFilmography } = useQuery({ + queryKey: ["actor", "filmography", 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", "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(() => { + if (filmography.length === 0) return null; + const focusedItem = filmography[focusedIndex] ?? filmography[0]; + return getBackdropUrl({ + api, + item: focusedItem, + quality: 90, + width: 1920, + }); + }, [api, filmography, focusedIndex]); + + // 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 }) => ( + + handleItemPress(filmItem)} + onFocus={() => setFocusedIndex(index)} + hasTVPreferredFocus={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 section */} + + + {t("item_card.appeared_in")} + + + {isLoadingFilmography ? ( + + + + ) : filmography.length === 0 ? ( + + {t("common.no_results")} + + ) : ( + filmItem.Id!} + renderItem={renderFilmographyItem} + showsHorizontalScrollIndicator={false} + initialNumToRender={6} + maxToRenderPerBatch={4} + windowSize={5} + removeClippedSubviews={false} + getItemLayout={getItemLayout} + style={{ overflow: "visible" }} + contentContainerStyle={{ + paddingVertical: SCALE_PADDING, + paddingHorizontal: SCALE_PADDING, + }} + /> + )} + + + + ); +};