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,
+ }}
+ />
+ )}
+
+
+
+ );
+};