import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; import { useAtomValue } from "jotai"; import React, { useMemo, useRef, useState } from "react"; import { Animated, Easing, Pressable, View, type ViewStyle, } from "react-native"; import { ProgressBar } from "@/components/common/ProgressBar"; import { Text } from "@/components/common/Text"; import { WatchedIndicator } from "@/components/WatchedIndicator"; import { useScaledTVPosterSizes } from "@/constants/TVPosterSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import { GlassPosterView, isGlassEffectAvailable, } from "@/modules/glass-poster"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { runtimeTicksToMinutes } from "@/utils/time"; export interface TVPosterCardProps { item: BaseItemDto; /** Poster orientation: vertical = 10:15 (portrait), horizontal = 16:9 (landscape) */ orientation?: "vertical" | "horizontal"; /** Show text below the poster (title, subtitle) - default: true */ showText?: boolean; /** Show progress bar - default: true for items with progress */ showProgress?: boolean; /** Show watched indicator - default: true */ showWatchedIndicator?: boolean; // Focus props hasTVPreferredFocus?: boolean; disabled?: boolean; /** When true, the item remains focusable even when disabled (for navigation purposes) */ focusableWhenDisabled?: boolean; /** Shows a "Now Playing" badge on the card */ isCurrent?: boolean; /** Show a play button overlay */ showPlayButton?: boolean; // Handlers onPress: () => void; onLongPress?: () => void; onFocus?: () => void; onBlur?: () => void; /** Setter function for the ref (for focus guide destinations) */ refSetter?: (ref: View | null) => void; /** Custom width - overrides default based on orientation */ width?: number; /** Custom style for the outer container */ style?: ViewStyle; /** Glow color for focus state */ glowColor?: "white" | "purple"; /** Scale amount for focus animation */ scaleAmount?: number; /** Custom image URL getter - if not provided, uses smart URL logic */ imageUrlGetter?: (item: BaseItemDto) => string | undefined; } /** * TVPosterCard - Unified poster component for TV interface. * * Combines image rendering, focus handling, and text display into a single component. * Supports both portrait (10:15) and landscape (16:9) orientations. * * Features: * - Glass effect on tvOS 26+ with fallback * - Focus handling with scale animation and glow * - Progress bar and watched indicator * - Smart subtitle text based on item type * - "Now Playing" badge for current items */ export const TVPosterCard: React.FC = ({ item, orientation = "vertical", showText = true, showProgress = true, showWatchedIndicator = true, hasTVPreferredFocus = false, disabled = false, focusableWhenDisabled = false, isCurrent = false, showPlayButton = false, onPress, onLongPress, onFocus: onFocusProp, onBlur: onBlurProp, refSetter, width: customWidth, style, glowColor = "white", scaleAmount = 1.05, imageUrlGetter, }) => { const api = useAtomValue(apiAtom); const posterSizes = useScaledTVPosterSizes(); const typography = useScaledTVTypography(); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; // Determine width based on orientation const width = useMemo(() => { if (customWidth) return customWidth; return orientation === "horizontal" ? posterSizes.episode : posterSizes.poster; }, [customWidth, orientation, posterSizes]); const aspectRatio = orientation === "horizontal" ? 16 / 9 : 10 / 15; // Smart image URL selection const imageUrl = useMemo(() => { // Use custom getter if provided if (imageUrlGetter) { return imageUrlGetter(item) ?? null; } if (!api) return null; // Horizontal orientation: prefer thumbs/backdrops for landscape images if (orientation === "horizontal") { // Episode: prefer series thumb image for consistent look (like hero section) if (item.Type === "Episode") { // First try parent/series thumb (horizontal series artwork) if (item.ParentBackdropItemId && item.ParentThumbImageTag) { return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; } // Fall back to episode's own primary image if (item.ImageTags?.Primary) { return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`; } // Last resort: try primary without tag return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; } // Movie/Series/Program: prefer thumb over primary if (item.ImageTags?.Thumb) { return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ImageTags.Thumb}`; } return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; } // Vertical orientation: use primary image // For episodes, get the series primary image if ( item.Type === "Episode" && item.SeriesId && item.SeriesPrimaryImageTag ) { return `${api.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=${width * 3}&quality=80&tag=${item.SeriesPrimaryImageTag}`; } return getPrimaryImageUrl({ api, item, width: width * 2, // 2x for quality on large screens }); }, [api, item, orientation, width, imageUrlGetter]); // Progress calculation const progress = useMemo(() => { if (!showProgress) return 0; if (item.Type === "Program") { if (!item.StartDate || !item.EndDate) return 0; const startDate = new Date(item.StartDate); const endDate = new Date(item.EndDate); const now = new Date(); const total = endDate.getTime() - startDate.getTime(); if (total <= 0) return 0; const elapsed = now.getTime() - startDate.getTime(); return (elapsed / total) * 100; } return item.UserData?.PlayedPercentage || 0; }, [item, showProgress]); const isWatched = showWatchedIndicator && item.UserData?.Played === true; // Blurhash for placeholder const blurhash = useMemo(() => { const key = item.ImageTags?.Primary as string; return item.ImageBlurHashes?.Primary?.[key]; }, [item]); // Glass effect availability const useGlass = isGlassEffectAvailable(); // Focus animation const animateTo = (value: number) => Animated.timing(scale, { toValue: value, duration: 150, easing: Easing.out(Easing.quad), useNativeDriver: true, }).start(); const shadowColor = glowColor === "white" ? "#ffffff" : "#a855f7"; // Text rendering helpers const renderSubtitle = () => { if (!showText) return null; // Episode: S#:E# • duration if (item.Type === "Episode") { const season = item.ParentIndexNumber; const ep = item.IndexNumber; const episodeLabel = season !== undefined && ep !== undefined ? `S${season}:E${ep}` : null; const duration = item.RunTimeTicks ? runtimeTicksToMinutes(item.RunTimeTicks) : null; return ( {episodeLabel && ( {episodeLabel} )} {duration && ( <> {duration} )} ); } // Program: channel name if (item.Type === "Program" && item.ChannelName) { return ( {item.ChannelName} ); } // MusicAlbum: artist if (item.Type === "MusicAlbum") { const artist = item.AlbumArtist || item.Artists?.join(", "); if (artist) { return ( {artist} ); } } // Audio: artist if (item.Type === "Audio") { const artist = item.Artists?.join(", ") || item.AlbumArtist; if (artist) { return ( {artist} ); } } // Playlist: track count if (item.Type === "Playlist" && item.ChildCount) { return ( {item.ChildCount} tracks ); } // Default: production year if (item.ProductionYear) { return ( {item.ProductionYear} ); } return null; }; // Now Playing badge component const NowPlayingBadge = isCurrent ? ( Now Playing ) : null; // Play button overlay component const PlayButtonOverlay = showPlayButton ? ( ) : null; // Render poster image const renderPosterImage = () => { // Empty placeholder when no URL if (!imageUrl) { return ( ); } // Glass effect rendering (tvOS 26+) if (useGlass) { return ( {PlayButtonOverlay} {NowPlayingBadge} ); } // Fallback rendering for older tvOS versions return ( {PlayButtonOverlay} {NowPlayingBadge} ); }; // Render title based on item type const renderTitle = () => { if (!showText) return null; // Episode: show episode name as title if (item.Type === "Episode") { return ( {item.Name} ); } // MusicArtist: centered text if (item.Type === "MusicArtist") { return ( {item.Name} ); } // Default: show name return ( {item.Name} ); }; return ( { setFocused(true); // Only animate scale when not using glass effect (glass handles its own focus visual) if (!useGlass) { animateTo(scaleAmount); } onFocusProp?.(); }} onBlur={() => { setFocused(false); if (!useGlass) { animateTo(1); } onBlurProp?.(); }} hasTVPreferredFocus={hasTVPreferredFocus && !disabled} disabled={disabled && !focusableWhenDisabled} focusable={!disabled || focusableWhenDisabled} > {renderPosterImage()} {/* Text below poster */} {showText && ( {item.Type === "Episode" ? ( <> {renderSubtitle()} {renderTitle()} ) : ( <> {renderTitle()} {renderSubtitle()} )} )} ); };