From 8f74c3edc7159d107f8f7af0c975744b8f588304 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 16 Jan 2026 23:36:15 +0100 Subject: [PATCH] feat(tv): actors and stuff --- components/ItemContent.tv.tsx | 342 +++++++++++++++++++++++++++++++++- 1 file changed, 341 insertions(+), 1 deletion(-) diff --git a/components/ItemContent.tv.tsx b/components/ItemContent.tv.tsx index 17fec0be..e3083a49 100644 --- a/components/ItemContent.tv.tsx +++ b/components/ItemContent.tv.tsx @@ -39,6 +39,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; +import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById"; import { runtimeTicksToMinutes } from "@/utils/time"; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); @@ -538,6 +539,230 @@ const TVOptionCard = React.forwardRef< ); }); +// Circular actor card with Apple TV style focus animations +const TVActorCard: React.FC<{ + person: { + Id?: string | null; + Name?: string | null; + Role?: string | null; + }; + apiBasePath?: string; + onPress: () => void; + hasTVPreferredFocus?: boolean; +}> = ({ person, apiBasePath, onPress, hasTVPreferredFocus }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + const imageUrl = person.Id + ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=200&fillHeight=200&quality=90` + : null; + + return ( + { + setFocused(true); + animateTo(1.08); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + {/* Circular image */} + + {imageUrl ? ( + + ) : ( + + + + )} + + + {/* Name */} + + {person.Name} + + + {/* Role */} + {person.Role && ( + + {person.Role} + + )} + + + ); +}; + +// Series/Season poster card with Apple TV style focus animations +const TVSeriesSeasonCard: React.FC<{ + title: string; + subtitle?: string; + imageUrl: string | null; + onPress: () => void; + hasTVPreferredFocus?: boolean; +}> = ({ title, subtitle, imageUrl, onPress, hasTVPreferredFocus }) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new Animated.Value(1)).current; + + const animateTo = (v: number) => + Animated.timing(scale, { + toValue: v, + duration: 150, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + {/* Poster image */} + + {imageUrl ? ( + + ) : ( + + + + )} + + + {/* Title */} + + {title} + + + {/* Subtitle */} + {subtitle && ( + + {subtitle} + + )} + + + ); +}; + // Button to open option selector const TVOptionButton: React.FC<{ label: string; @@ -853,9 +1078,29 @@ export const ItemContentTV: React.FC = React.memo( // Get director const director = item?.People?.find((p) => p.Type === "Director"); - // Get cast (first 3) + // Get cast (first 3 for text display) const cast = item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 3); + // Get full cast for visual display (up to 10 actors) + const fullCast = useMemo(() => { + return ( + item?.People?.filter((p) => p.Type === "Actor")?.slice(0, 10) ?? [] + ); + }, [item?.People]); + + // Series/Season image URLs for episodes + const seriesImageUrl = useMemo(() => { + if (item?.Type !== "Episode" || !item.SeriesId) return null; + return getPrimaryImageUrlById({ api, id: item.SeriesId, width: 300 }); + }, [api, item?.Type, item?.SeriesId]); + + const seasonImageUrl = useMemo(() => { + if (item?.Type !== "Episode") return null; + const seasonId = item.SeasonId || item.ParentId; + if (!seasonId) return null; + return getPrimaryImageUrlById({ api, id: seasonId, width: 300 }); + }, [api, item?.Type, item?.SeasonId, item?.ParentId]); + if (!item || !selectedOptions) return null; return ( @@ -1292,6 +1537,101 @@ export const ItemContentTV: React.FC = React.memo( )} + + {/* Visual Cast Section - Movies/Series/Episodes with circular actor cards */} + {(item.Type === "Movie" || + item.Type === "Series" || + item.Type === "Episode") && + fullCast.length > 0 && ( + + + {t("item_card.cast")} + + + {fullCast.map((person, index) => ( + { + if (person.Id) { + router.push(`/(auth)/persons/${person.Id}`); + } + }} + /> + ))} + + + )} + + {/* From this Series - Episode only */} + {item.Type === "Episode" && item.SeriesId && ( + + + {t("item_card.from_this_series") || "From this Series"} + + + {/* Series card */} + { + router.push(`/(auth)/series/${item.SeriesId}`); + }} + hasTVPreferredFocus={false} + /> + + {/* Season card */} + {(item.SeasonId || item.ParentId) && ( + { + router.push( + `/(auth)/series/${item.SeriesId}?seasonIndex=${item.ParentIndexNumber}`, + ); + }} + /> + )} + + + )}