import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { Image } from "expo-image"; import { LinearGradient } from "expo-linear-gradient"; import { useAtomValue } from "jotai"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Animated, Dimensions, Easing, FlatList, Platform, Pressable, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ProgressBar } from "@/components/common/ProgressBar"; import { Text } from "@/components/common/Text"; import { getItemNavigation } from "@/components/common/TouchableItemRouter"; import { type ScaledTVSizes, useScaledTVSizes } from "@/constants/TVSizes"; import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { GlassPosterView, isGlassEffectAvailable, } from "@/modules/glass-poster"; import { apiAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { runtimeTicksToMinutes } from "@/utils/time"; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window"); interface TVHeroCarouselProps { items: BaseItemDto[]; onItemFocus?: (item: BaseItemDto) => void; onItemLongPress?: (item: BaseItemDto) => void; } interface HeroCardProps { item: BaseItemDto; isFirst: boolean; sizes: ScaledTVSizes; onFocus: (item: BaseItemDto) => void; onPress: (item: BaseItemDto) => void; onLongPress?: (item: BaseItemDto) => void; } const HeroCard: React.FC = React.memo( ({ item, isFirst, sizes, onFocus, onPress, onLongPress }) => { const api = useAtomValue(apiAtom); const [focused, setFocused] = useState(false); const scale = useRef(new Animated.Value(1)).current; // Check if glass effect is available (tvOS 26+) const useGlass = Platform.OS === "ios" && isGlassEffectAvailable(); const posterUrl = useMemo(() => { if (!api) return null; // For episodes, always use series thumb if (item.Type === "Episode") { if (item.ParentThumbImageTag) { return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`; } if (item.SeriesId) { return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`; } } // For non-episodes, use item's own thumb/primary if (item.ImageTags?.Thumb) { return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`; } if (item.ImageTags?.Primary) { return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`; } return null; }, [api, item]); const animateTo = useCallback( (value: number) => Animated.timing(scale, { toValue: value, duration: 150, easing: Easing.out(Easing.quad), useNativeDriver: true, }).start(), [scale], ); const handleFocus = useCallback(() => { setFocused(true); animateTo(sizes.animation.focusScale); onFocus(item); }, [animateTo, onFocus, item, sizes.animation.focusScale]); const handleBlur = useCallback(() => { setFocused(false); animateTo(1); }, [animateTo]); const handlePress = useCallback(() => { onPress(item); }, [onPress, item]); const handleLongPress = useCallback(() => { onLongPress?.(item); }, [onLongPress, item]); // Use glass poster for tvOS 26+ if (useGlass && posterUrl) { const progress = item.UserData?.PlayedPercentage || 0; return ( ); } // Fallback for non-tvOS or older tvOS return ( {posterUrl ? ( ) : ( )} ); }, ); // Debounce delay to prevent rapid backdrop changes when navigating fast const BACKDROP_DEBOUNCE_MS = 300; export const TVHeroCarousel: React.FC = ({ items, onItemFocus, onItemLongPress, }) => { const typography = useScaledTVTypography(); const sizes = useScaledTVSizes(); const api = useAtomValue(apiAtom); const insets = useSafeAreaInsets(); const router = useRouter(); // Active item for featured display (debounced) const [activeItem, setActiveItem] = useState( items[0] || null, ); const debounceTimerRef = useRef | null>(null); // Cleanup debounce timer on unmount useEffect(() => { return () => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } }; }, []); // Crossfade animation state const [activeLayer, setActiveLayer] = useState<0 | 1>(0); const [layer0Url, setLayer0Url] = useState(null); const [layer1Url, setLayer1Url] = useState(null); const layer0Opacity = useRef(new Animated.Value(0)).current; const layer1Opacity = useRef(new Animated.Value(0)).current; // Get backdrop URL for active item const backdropUrl = useMemo(() => { if (!activeItem) return null; return getBackdropUrl({ api, item: activeItem, quality: 90, width: 1920, }); }, [api, activeItem]); // Get logo URL for active item const logoUrl = useMemo(() => { if (!activeItem) return null; return getLogoImageUrlById({ api, item: activeItem }); }, [api, activeItem]); // Crossfade effect for backdrop useEffect(() => { if (!backdropUrl) return; let isCancelled = false; const performCrossfade = async () => { try { await Image.prefetch(backdropUrl); } catch { // Continue even if prefetch fails } if (isCancelled) return; const incomingLayer = activeLayer === 0 ? 1 : 0; const incomingOpacity = incomingLayer === 0 ? layer0Opacity : layer1Opacity; const outgoingOpacity = incomingLayer === 0 ? layer1Opacity : layer0Opacity; if (incomingLayer === 0) { setLayer0Url(backdropUrl); } else { setLayer1Url(backdropUrl); } await new Promise((resolve) => setTimeout(resolve, 50)); if (isCancelled) return; 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) { setActiveLayer(incomingLayer); } }); }; performCrossfade(); return () => { isCancelled = true; }; }, [backdropUrl]); // Handle card focus with debounce const handleCardFocus = useCallback( (item: BaseItemDto) => { // Clear any pending debounce timer if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current); } // Set new timer to update active item after debounce delay debounceTimerRef.current = setTimeout(() => { setActiveItem(item); onItemFocus?.(item); }, BACKDROP_DEBOUNCE_MS); }, [onItemFocus], ); // Handle card press - navigate to item const handleCardPress = useCallback( (item: BaseItemDto) => { const navigation = getItemNavigation(item, "(home)"); router.push(navigation as any); }, [router], ); // Get metadata for active item const year = activeItem?.ProductionYear; const duration = activeItem?.RunTimeTicks ? runtimeTicksToMinutes(activeItem.RunTimeTicks) : null; const hasProgress = (activeItem?.UserData?.PlaybackPositionTicks ?? 0) > 0; const playedPercent = activeItem?.UserData?.PlayedPercentage ?? 0; // Get display title const displayTitle = useMemo(() => { if (!activeItem) return ""; if (activeItem.Type === "Episode") { return activeItem.SeriesName || activeItem.Name || ""; } return activeItem.Name || ""; }, [activeItem]); // Get subtitle for episodes const episodeSubtitle = useMemo(() => { if (!activeItem || activeItem.Type !== "Episode") return null; return `S${activeItem.ParentIndexNumber} E${activeItem.IndexNumber} ยท ${activeItem.Name}`; }, [activeItem]); // Memoize hero items to prevent re-renders const heroItems = useMemo(() => items.slice(0, 8), [items]); // Memoize renderItem for FlatList const renderHeroCard = useCallback( ({ item, index }: { item: BaseItemDto; index: number }) => ( ), [handleCardFocus, handleCardPress, onItemLongPress, sizes], ); // Memoize keyExtractor const keyExtractor = useCallback((item: BaseItemDto) => item.Id!, []); if (items.length === 0) return null; const heroHeight = SCREEN_HEIGHT * sizes.padding.heroHeight; return ( {/* Backdrop layers with crossfade */} {/* Layer 0 */} {layer0Url && ( )} {/* Layer 1 */} {layer1Url && ( )} {/* Gradient overlays */} {/* Horizontal gradient for left side text contrast */} {/* Content overlay */} {/* Logo or Title */} {logoUrl ? ( ) : ( {displayTitle} )} {/* Episode subtitle */} {episodeSubtitle && ( {episodeSubtitle} )} {/* Description */} {activeItem?.Overview && ( {activeItem.Overview} )} {/* Metadata badges */} {year && ( {year} )} {duration && ( {duration} )} {activeItem?.OfficialRating && ( {activeItem.OfficialRating} )} {hasProgress && ( {Math.round(playedPercent)}% )} {/* Thumbnail carousel */} ); };