import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, ViewStyle, TextStyle, StatusBar, Image, } from 'react-native'; import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native'; import { useTranslation } from 'react-i18next'; import { TMDBService } from '../../services/tmdbService'; import { RootStackParamList } from '../../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; import FastImage from '@d11/react-native-fast-image'; import { MaterialIcons, Entypo } from '@expo/vector-icons'; import PlayerPlayIconBlack from '../../../assets/player-icons/ic_player_play_black.svg'; import Animated, { FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming, Easing, withDelay, runOnJS, interpolate, Extrapolation, useAnimatedScrollHandler, SharedValue, useAnimatedReaction, } from 'react-native-reanimated'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { StreamingContent } from '../../services/catalogService'; import { useTheme } from '../../contexts/ThemeContext'; import { logger } from '../../utils/logger'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSettings } from '../../hooks/useSettings'; import { useTrailer } from '../../contexts/TrailerContext'; import TrailerService, { TrailerPlaybackSource } from '../../services/trailerService'; import TrailerPlayer from '../video/TrailerPlayer'; import { useLibrary } from '../../hooks/useLibrary'; import { useToast } from '../../contexts/ToastContext'; import { useTraktContext } from '../../contexts/TraktContext'; import { BlurView as ExpoBlurView } from 'expo-blur'; import { useWatchProgress } from '../../hooks/useWatchProgress'; import { streamCacheService } from '../../services/streamCacheService'; interface AppleTVHeroProps { featuredContent: StreamingContent | null; allFeaturedContent?: StreamingContent[]; loading?: boolean; onRetry?: () => void; scrollY?: SharedValue; // Optional scroll position for parallax } const { width, height } = Dimensions.get('window'); // Get status bar height const STATUS_BAR_HEIGHT = StatusBar.currentHeight || 0; // Calculate hero height - 85% of screen height const HERO_HEIGHT = height * 0.85; // Animated Pagination Dot Component const PaginationDot: React.FC<{ isActive: boolean; isNext: boolean; dragProgress: SharedValue; onPress: () => void; }> = React.memo( ({ isActive, isNext, dragProgress, onPress }) => { const animatedStyle = useAnimatedStyle(() => { // Base values const activeWidth = 32; const inactiveWidth = 8; const activeOpacity = 0.9; const inactiveOpacity = 0.3; // Calculate target width and opacity based on state let targetWidth = isActive ? activeWidth : inactiveWidth; let targetOpacity = isActive ? activeOpacity : inactiveOpacity; // If this is the next dot during drag, interpolate between inactive and active if (isNext && dragProgress.value > 0) { targetWidth = interpolate( dragProgress.value, [0, 1], [inactiveWidth, activeWidth], Extrapolation.CLAMP ); targetOpacity = interpolate( dragProgress.value, [0, 1], [inactiveOpacity, activeOpacity], Extrapolation.CLAMP ); } // If this is the current active dot during drag, interpolate from active to inactive if (isActive && dragProgress.value > 0) { targetWidth = interpolate( dragProgress.value, [0, 1], [activeWidth, inactiveWidth], Extrapolation.CLAMP ); targetOpacity = interpolate( dragProgress.value, [0, 1], [activeOpacity, inactiveOpacity], Extrapolation.CLAMP ); } return { width: withTiming(targetWidth, { duration: 300, easing: Easing.out(Easing.cubic), }), opacity: withTiming(targetOpacity, { duration: 300, easing: Easing.out(Easing.cubic), }), }; }); return ( ); } ); const AppleTVHero: React.FC = ({ featuredContent, allFeaturedContent, loading, onRetry, scrollY: externalScrollY, }) => { const { t } = useTranslation(); const navigation = useNavigation>(); const isFocused = useIsFocused(); const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); const { settings, updateSetting } = useSettings(); const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer(); const { toggleLibrary, isInLibrary: checkIsInLibrary } = useLibrary(); const { showSaved, showTraktSaved, showRemoved, showTraktRemoved } = useToast(); const { isAuthenticated: isTraktAuthenticated } = useTraktContext(); // Library and watch state const [inLibrary, setInLibrary] = useState(false); const [isInWatchlist, setIsInWatchlist] = useState(false); const [isWatched, setIsWatched] = useState(false); const [shouldResume, setShouldResume] = useState(false); const [type, setType] = useState<'movie' | 'series'>('movie'); // Shared value for scroll position (for parallax effects) const internalScrollY = useSharedValue(0); const scrollY = externalScrollY || internalScrollY; const [isOutOfView, setIsOutOfView] = useState(false); // Track if hero is in view useAnimatedReaction( () => scrollY.value, (currentScrollY) => { // If hero is more than 80% scrolled out of view, consider it out of view const outOfView = currentScrollY > HERO_HEIGHT * 0.8; if (outOfView !== isOutOfView) { runOnJS(setIsOutOfView)(outOfView); } }, [isOutOfView] ); // Determine items to display const items = useMemo(() => { if (allFeaturedContent && allFeaturedContent.length > 0) { return allFeaturedContent.slice(0, 8); // Limit to 8 items for performance } return featuredContent ? [featuredContent] : []; }, [allFeaturedContent, featuredContent]); const [currentIndex, setCurrentIndex] = useState(0); const [bannerLoaded, setBannerLoaded] = useState>({}); const [logoLoaded, setLogoLoaded] = useState>({}); const [logoError, setLogoError] = useState>({}); const [logoHeights, setLogoHeights] = useState>({}); const autoPlayTimerRef = useRef(null); const lastInteractionRef = useRef(Date.now()); const [initialLoadComplete, setInitialLoadComplete] = useState(false); // Trailer state const [trailerSource, setTrailerSource] = useState(null); const [trailerLoading, setTrailerLoading] = useState(false); const [trailerError, setTrailerError] = useState(false); const [trailerReady, setTrailerReady] = useState(false); const [trailerPreloaded, setTrailerPreloaded] = useState(false); const [trailerShouldBePaused, setTrailerShouldBePaused] = useState(false); const trailerVideoRef = useRef(null); // Use ref to avoid re-fetching trailer when trailerMuted changes const showTrailersEnabled = useRef(settings?.showTrailers ?? false); // Update ref when showTrailers setting changes useEffect(() => { showTrailersEnabled.current = settings?.showTrailers ?? false; }, [settings?.showTrailers]); const currentItem = items[currentIndex] || null; // Use watch progress hook const { watchProgress, getPlayButtonText: getProgressPlayButtonText, loadWatchProgress } = useWatchProgress( currentItem?.id || '', type, undefined, [] // Pass episodes if you have them for series ); // Animation values const dragProgress = useSharedValue(0); const dragDirection = useSharedValue(0); // -1 for left, 1 for right const isDragging = useSharedValue(0); // 1 when dragging, 0 when not const logoOpacity = useSharedValue(1); const [nextIndex, setNextIndex] = useState(currentIndex); const thumbnailOpacity = useSharedValue(1); const trailerOpacity = useSharedValue(0); const trailerMuted = settings?.trailerMuted ?? true; // Initialize to 0 for smooth fade-in const heroOpacity = useSharedValue(0); // Handler for trailer end const handleTrailerEnd = useCallback(() => { logger.info('[AppleTVHero] Trailer ended'); setTrailerPlaying(false); // Fade back to thumbnail trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); }, [setTrailerPlaying, trailerOpacity, thumbnailOpacity]); // Animated style for trailer container - 60% height with zoom const trailerContainerStyle = useAnimatedStyle(() => { // Faster fade out during drag - complete fade by 0.3 progress instead of 1.0 const dragFade = interpolate( dragProgress.value, [0, 0.05, 0.1, 0.15, 0.2, 0.3], [1, 0.85, 0.65, 0.4, 0.15, 0], Extrapolation.CLAMP ); return { position: 'absolute', top: 0, left: 0, right: 0, height: HERO_HEIGHT * 0.9, // 90% of hero height overflow: 'hidden', opacity: trailerOpacity.value * dragFade, }; }); // Animated style for trailer video - zoomed in 5% const trailerVideoStyle = useAnimatedStyle(() => { return { width: '100%', height: '100%', transform: [{ scale: 1.05 }], // 5% zoom }; }); // Parallax style for background images - disabled during drag const backgroundParallaxStyle = useAnimatedStyle(() => { 'worklet'; const scrollYValue = scrollY.value; // Keep parallax active during drag to prevent jumps // if (isDragging.value > 0) { ... } // Pre-calculated constants - start at 1.0 for normal size const DEFAULT_ZOOM = 1.0; const SCROLL_UP_MULTIPLIER = 0.002; const SCROLL_DOWN_MULTIPLIER = 0.0001; const MAX_SCALE = 1.3; const PARALLAX_FACTOR = 0.3; // Optimized scale calculation with minimal branching const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER; const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER; const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE); // Single parallax calculation const parallaxOffset = scrollYValue * PARALLAX_FACTOR; return { transform: [ { scale }, { translateY: parallaxOffset } ], }; }); // Parallax style for trailer - disabled during drag const trailerParallaxStyle = useAnimatedStyle(() => { 'worklet'; const scrollYValue = scrollY.value; // Keep parallax active during drag to prevent jumps // if (isDragging.value > 0) { ... } // Pre-calculated constants - start at 1.0 for normal size const DEFAULT_ZOOM = 1.0; const SCROLL_UP_MULTIPLIER = 0.0015; const SCROLL_DOWN_MULTIPLIER = 0.0001; const MAX_SCALE = 1.2; const PARALLAX_FACTOR = 0.2; // Slower than background for depth // Optimized scale calculation with minimal branching const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER; const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER; const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE); // Single parallax calculation const parallaxOffset = scrollYValue * PARALLAX_FACTOR; return { transform: [ { scale }, { translateY: parallaxOffset } ], }; }); // Reset loaded states when items change useEffect(() => { setBannerLoaded({}); setLogoLoaded({}); setLogoError({}); setLogoHeights({}); }, [items.length]); // Mark initial load as complete after a short delay useEffect(() => { const timer = setTimeout(() => { setInitialLoadComplete(true); }, 100); return () => clearTimeout(timer); }, []); // Smooth fade-in when content loads useEffect(() => { if (currentItem && !loading) { heroOpacity.value = withTiming(1, { duration: 800, easing: Easing.out(Easing.cubic), }); } }, [currentItem, loading, heroOpacity]); // Stop trailer when screen loses focus or scrolled out of view useEffect(() => { if (!isFocused || isOutOfView) { // Pause this screen's trailer setTrailerShouldBePaused(true); setTrailerPlaying(false); // Fade out trailer trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); if (!isFocused) { logger.info('[AppleTVHero] Screen lost focus - pausing trailer'); } else { logger.info('[AppleTVHero] Scrolled out of view - pausing trailer'); } } else { // Screen gained focus and is in view - allow trailer to resume if it was ready setTrailerShouldBePaused(false); // If trailer was ready and loaded, restore the video opacity if (trailerReady && trailerSource?.videoUrl) { logger.info('[AppleTVHero] Screen in focus and in view - restoring trailer'); thumbnailOpacity.value = withTiming(0, { duration: 800 }); trailerOpacity.value = withTiming(1, { duration: 800 }); setTrailerPlaying(true); } } }, [isFocused, isOutOfView, setTrailerPlaying, trailerOpacity, thumbnailOpacity, trailerReady, trailerSource]); // Listen to navigation events to stop trailer when navigating to other screens useEffect(() => { const unsubscribe = navigation.addListener('blur', () => { // Screen is blurred (navigated away) setTrailerPlaying(false); trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); logger.info('[AppleTVHero] Navigation blur event - stopping trailer'); }); return () => { unsubscribe(); // Stop trailer when component unmounts setTrailerPlaying(false); logger.info('[AppleTVHero] Component unmounting - stopping trailer'); }; }, [navigation, setTrailerPlaying, trailerOpacity, thumbnailOpacity]); // Fetch trailer URL when current item changes useEffect(() => { let alive = true; const fetchTrailer = async () => { if (!currentItem || !showTrailersEnabled.current) { setTrailerSource(null); return; } // Reset trailer state when item changes setTrailerLoading(true); setTrailerError(false); setTrailerReady(false); setTrailerPreloaded(false); setTrailerPlaying(false); // Fade out any existing trailer trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); try { // Extract TMDB ID if available const tmdbId = currentItem.id?.startsWith('tmdb:') ? currentItem.id.replace('tmdb:', '') : undefined; if (!tmdbId) { logger.info('[AppleTVHero] No TMDB ID for:', currentItem.name, '- skipping trailer'); setTrailerSource(null); setTrailerLoading(false); return; } const contentType = currentItem.type === 'series' ? 'tv' : 'movie'; logger.info('[AppleTVHero] Fetching TMDB videos for:', currentItem.name, 'tmdbId:', tmdbId); // Fetch video list from TMDB to get the YouTube video ID const tmdbApiKey = await TMDBService.getInstance().getApiKey(); const videosRes = await fetch( `https://api.themoviedb.org/3/${contentType}/${tmdbId}/videos?api_key=${tmdbApiKey}` ); if (!alive) return; if (!videosRes.ok) { logger.warn('[AppleTVHero] TMDB videos fetch failed:', videosRes.status); setTrailerSource(null); setTrailerLoading(false); return; } const videosData = await videosRes.json(); const results: any[] = videosData.results ?? []; // Pick best YouTube trailer: any trailer > teaser > any YouTube video const pick = results.find((v) => v.site === 'YouTube' && v.type === 'Trailer') ?? results.find((v) => v.site === 'YouTube' && v.type === 'Teaser') ?? results.find((v) => v.site === 'YouTube'); if (!alive) return; if (!pick) { logger.info('[AppleTVHero] No YouTube video found for:', currentItem.name); setTrailerSource(null); setTrailerLoading(false); return; } logger.info('[AppleTVHero] Extracting stream for videoId:', pick.key, currentItem.name); const source = await TrailerService.getTrailerPlaybackSourceFromVideoId( pick.key, currentItem.name ); if (!alive) return; if (source) { setTrailerSource(source); } else { logger.info('[AppleTVHero] No stream extracted for:', currentItem.name); setTrailerSource(null); } } catch (error) { if (!alive) return; logger.error('[AppleTVHero] Error fetching trailer:', error); setTrailerError(true); setTrailerSource(null); } finally { if (alive) { setTrailerLoading(false); } } }; fetchTrailer(); return () => { alive = false; }; }, [currentItem, currentIndex]); // Removed settings?.showTrailers from dependencies // Handle trailer preloaded // FIX: Set global trailer playing to true HERE — before the visible player mounts — // so that when the visible player's autoPlay prop is evaluated it is already true, // eliminating the race condition that previously caused the global state effect in // TrailerPlayer to immediately pause the video on first render. const handleTrailerPreloaded = useCallback(() => { if (isFocused && !isOutOfView && !trailerShouldBePaused) { setTrailerPlaying(true); } setTrailerPreloaded(true); logger.info('[AppleTVHero] Trailer preloaded successfully'); }, [isFocused, isOutOfView, trailerShouldBePaused, setTrailerPlaying]); // Handle trailer ready to play const handleTrailerReady = useCallback(() => { setTrailerReady(true); // Smooth crossfade: thumbnail out, trailer in thumbnailOpacity.value = withTiming(0, { duration: 800 }); trailerOpacity.value = withTiming(1, { duration: 800 }); logger.info('[AppleTVHero] Trailer ready - starting playback'); // Auto-start trailer setTrailerPlaying(true); }, [thumbnailOpacity, trailerOpacity, setTrailerPlaying]); // Handle trailer error const handleTrailerError = useCallback(() => { setTrailerError(true); setTrailerReady(false); setTrailerPlaying(false); // Fade back to thumbnail trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); logger.error('[AppleTVHero] Trailer playback error'); }, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]); // Update state when current item changes and load watch progress useEffect(() => { if (currentItem) { setType(currentItem.type as 'movie' | 'series'); checkItemStatus(currentItem.id); loadWatchProgress(); } }, [currentItem, loadWatchProgress]); // Update play button text and watched state when watch progress changes useEffect(() => { if (currentItem) { const buttonText = getProgressPlayButtonText(); // Use internal state for resume logic instead of string comparison setShouldResume(buttonText === 'Resume'); // Update watched state based on progress if (watchProgress) { const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; setIsWatched(progressPercent >= 85); // Consider watched if 85% or more completed } else { setIsWatched(false); } } }, [watchProgress, getProgressPlayButtonText, currentItem]); // Function to check item status const checkItemStatus = useCallback(async (itemId: string) => { try { // Check if item is in library const libraryStatus = checkIsInLibrary(itemId); setInLibrary(libraryStatus); // TODO: Check Trakt watchlist status if authenticated if (isTraktAuthenticated) { // await traktService.isInWatchlist(itemId); setIsInWatchlist(Math.random() > 0.5); // Replace with actual Trakt call } } catch (error) { logger.error('[AppleTVHero] Error checking item status:', error); } }, [checkIsInLibrary, isTraktAuthenticated]); // Update the handleSaveAction function: const handleSaveAction = useCallback(async (e?: any) => { if (e) { e.stopPropagation(); e.preventDefault(); } if (!currentItem) return; const wasInLibrary = inLibrary; const wasInWatchlist = isInWatchlist; // Update local state immediately for responsiveness setInLibrary(!wasInLibrary); try { // Toggle library using the useLibrary hook const success = await toggleLibrary(currentItem); if (success) { logger.info('[AppleTVHero] Successfully toggled library:', currentItem.name); } else { logger.warn('[AppleTVHero] Library toggle returned false'); } // If authenticated with Trakt, also toggle Trakt watchlist if (isTraktAuthenticated) { setIsInWatchlist(!wasInWatchlist); // TODO: Replace with your actual Trakt service call // await traktService.toggleWatchlist(currentItem.id, !wasInWatchlist); logger.info('[AppleTVHero] Toggled Trakt watchlist'); } } catch (error) { logger.error('[AppleTVHero] Error toggling library:', error); // Revert state on error setInLibrary(wasInLibrary); if (isTraktAuthenticated) { setIsInWatchlist(wasInWatchlist); } } }, [currentItem, inLibrary, isInWatchlist, isTraktAuthenticated, toggleLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]); // Play button handler - navigates to Streams screen with progress data if available const handlePlayAction = useCallback(async () => { logger.info('[AppleTVHero] Play button pressed for:', currentItem?.name); if (!currentItem) return; // Stop any playing trailer try { setTrailerPlaying(false); } catch { } // Check if we should resume based on watch progress const shouldResume = watchProgress && watchProgress.currentTime > 0 && (watchProgress.currentTime / watchProgress.duration) < 0.85; logger.info('[AppleTVHero] Should resume:', shouldResume, watchProgress); try { // Check if we have a cached stream for this content const episodeId = currentItem.type === 'series' && watchProgress?.episodeId ? watchProgress.episodeId : undefined; logger.info('[AppleTVHero] Looking for cached stream with episodeId:', episodeId); const cachedStream = await streamCacheService.getCachedStream(currentItem.id, currentItem.type, episodeId); if (cachedStream && cachedStream.stream?.url) { // We have a valid cached stream, navigate directly to player logger.info('[AppleTVHero] Using cached stream for:', currentItem.name); // Determine the player route based on platform const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; // Navigate directly to player with cached stream data AND RESUME DATA navigation.navigate(playerRoute as any, { uri: cachedStream.stream.url, title: cachedStream.metadata?.name || currentItem.name, episodeTitle: cachedStream.episodeTitle, season: cachedStream.season, episode: cachedStream.episode, quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined, year: cachedStream.metadata?.year || currentItem.year, streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name, streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream', headers: cachedStream.stream.headers || undefined, id: currentItem.id, type: currentItem.type, episodeId: episodeId, imdbId: cachedStream.imdbId || cachedStream.metadata?.imdbId || currentItem.imdb_id, backdrop: cachedStream.metadata?.backdrop || currentItem.banner, videoType: undefined, // Let player auto-detect // ADD RESUME DATA if we should resume ...(shouldResume && watchProgress && { resumeTime: watchProgress.currentTime, duration: watchProgress.duration }) } as any); return; } // No cached stream, navigate to Streams screen with resume data logger.info('[AppleTVHero] No cached stream, navigating to StreamsScreen for:', currentItem.name); const navigationParams: any = { id: currentItem.id, type: currentItem.type, title: currentItem.name, addonId: currentItem.addonId, metadata: { poster: currentItem.poster, banner: currentItem.banner, releaseInfo: currentItem.releaseInfo, genres: currentItem.genres } }; // Add resume data if we have progress that's not near completion if (shouldResume && watchProgress) { navigationParams.resumeTime = watchProgress.currentTime; navigationParams.duration = watchProgress.duration; navigationParams.episodeId = watchProgress.episodeId; logger.info('[AppleTVHero] Passing resume data to Streams:', watchProgress.currentTime, watchProgress.duration); } navigation.navigate('Streams', navigationParams); } catch (error) { logger.error('[AppleTVHero] Error handling play action:', error); // Fallback to StreamsScreen on any error navigation.navigate('Streams', { id: currentItem.id, type: currentItem.type, title: currentItem.name, addonId: currentItem.addonId, metadata: { poster: currentItem.poster, banner: currentItem.banner, releaseInfo: currentItem.releaseInfo, genres: currentItem.genres } }); } }, [currentItem, navigation, setTrailerPlaying, watchProgress]); // Handle fullscreen toggle const handleFullscreenToggle = useCallback(async () => { try { logger.info('[AppleTVHero] Fullscreen button pressed'); if (trailerVideoRef.current) { await trailerVideoRef.current.presentFullscreenPlayer(); } } catch (error) { logger.error('[AppleTVHero] Error toggling fullscreen:', error); } }, []); // Handle mute toggle const handleMuteToggle = useCallback(() => { logger.info('[AppleTVHero] Mute toggle pressed, current:', trailerMuted); updateSetting('trailerMuted', !trailerMuted); }, [trailerMuted, updateSetting]); // Auto-advance timer - PAUSE when trailer is playing or out of view const startAutoPlay = useCallback(() => { if (autoPlayTimerRef.current) { clearTimeout(autoPlayTimerRef.current); } if (items.length <= 1) return; // Don't auto-advance if trailer is playing or out of view if ((globalTrailerPlaying && trailerReady) || isOutOfView) { if (isOutOfView) { logger.info('[AppleTVHero] Auto-rotation paused - out of view'); } else { logger.info('[AppleTVHero] Auto-rotation paused - trailer is playing'); } return; } autoPlayTimerRef.current = setTimeout(() => { const timeSinceInteraction = Date.now() - lastInteractionRef.current; // Only auto-advance if user hasn't interacted recently (5 seconds) and no trailer playing if (timeSinceInteraction >= 5000 && (!globalTrailerPlaying || !trailerReady) && !isOutOfView) { // Set next index preview for crossfade const nextIdx = (currentIndex + 1) % items.length; setNextIndex(nextIdx); // Set drag direction for slide animation (left/next) dragDirection.value = -1; // Animate crossfade before changing index dragProgress.value = withTiming( 1, { duration: 500, easing: Easing.out(Easing.cubic), }, (finished) => { if (finished) { runOnJS(setCurrentIndex)(nextIdx); } } ); } else { // Retry after remaining time startAutoPlay(); } }, 25000); // Auto-advance every 25 seconds }, [items.length, globalTrailerPlaying, trailerReady, currentIndex, dragDirection, dragProgress, isOutOfView]); useEffect(() => { startAutoPlay(); return () => { if (autoPlayTimerRef.current) { clearTimeout(autoPlayTimerRef.current); } }; }, [startAutoPlay, currentIndex, globalTrailerPlaying, trailerReady]); // Reset drag progress and animate logo when index changes useEffect(() => { // Instant reset - no extra fade animation dragProgress.value = 0; setNextIndex(currentIndex); // Immediately hide trailer and show thumbnail when index changes trailerOpacity.value = 0; thumbnailOpacity.value = 1; setTrailerPlaying(false); // Faster logo fade logoOpacity.value = 0; logoOpacity.value = withDelay( 80, withTiming(1, { duration: 250, easing: Easing.out(Easing.cubic), }) ); }, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]); // Callback for updating interaction time const updateInteractionTime = useCallback(() => { lastInteractionRef.current = Date.now(); }, []); // Callback for navigating to previous item const goToPrevious = useCallback(() => { setCurrentIndex((prev) => (prev - 1 + items.length) % items.length); }, [items.length]); // Callback for navigating to next item const goToNext = useCallback(() => { setCurrentIndex((prev) => (prev + 1) % items.length); }, [items.length]); // Callback for setting next preview index const setPreviewIndex = useCallback((index: number) => { setNextIndex(index); }, []); // Callback to hide trailer when drag starts const hideTrailerOnDrag = useCallback(() => { setTrailerPlaying(false); }, [setTrailerPlaying]); // Swipe gesture handler with live preview - only horizontal const panGesture = useMemo( () => Gesture.Pan() .activeOffsetX([-5, 5]) // Smaller activation area - more sensitive .failOffsetY([-15, 15]) // Fail if vertical movement is detected .onStart(() => { // Mark as dragging to disable parallax isDragging.value = 1; // Determine which direction and set preview runOnJS(updateInteractionTime)(); // Immediately stop trailer playback when drag starts runOnJS(hideTrailerOnDrag)(); }) .onUpdate((event) => { const translationX = event.translationX; // Use larger width multiplier for smoother visual feedback on small swipes const progress = Math.abs(translationX) / (width * 1.2); // Update drag progress (0 to 1) with eased curve dragProgress.value = Math.min(progress, 1); // Track drag direction: positive = right (previous), negative = left (next) if (translationX > 0) { dragDirection.value = 1; // Swiping right - show previous } else if (translationX < 0) { dragDirection.value = -1; // Swiping left - show next } // Determine preview index based on direction if (translationX > 0) { // Swiping right - show previous const prevIdx = (currentIndex - 1 + items.length) % items.length; runOnJS(setPreviewIndex)(prevIdx); } else if (translationX < 0) { // Swiping left - show next const nextIdx = (currentIndex + 1) % items.length; runOnJS(setPreviewIndex)(nextIdx); } }) .onEnd((event) => { const velocity = event.velocityX; const translationX = event.translationX; const swipeThreshold = width * 0.16; // 16% threshold for swipe detection if (Math.abs(translationX) > swipeThreshold || Math.abs(velocity) > 300) { // Complete the swipe - animate to full opacity before navigation dragProgress.value = withTiming( 1, { duration: 300, easing: Easing.out(Easing.cubic), }, (finished) => { if (finished) { // Re-enable parallax after navigation completes isDragging.value = withTiming(0, { duration: 200 }); if (translationX > 0) { runOnJS(goToPrevious)(); } else { runOnJS(goToNext)(); } } } ); } else { // Cancel the swipe - animate back with smooth ease dragProgress.value = withTiming(0, { duration: 450, easing: Easing.bezier(0.25, 0.1, 0.25, 1), // Custom ease-out for buttery smooth return }); // Re-enable parallax immediately on cancel isDragging.value = withTiming(0, { duration: 200 }); } }), [goToPrevious, goToNext, updateInteractionTime, setPreviewIndex, hideTrailerOnDrag, currentIndex, items.length] ); // Animated styles for next image only - smooth crossfade + slide during drag const nextImageStyle = useAnimatedStyle(() => { // Silky-smooth 10-point ease curve for cinematic crossfade const opacity = interpolate( dragProgress.value, [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1], [0, 0.05, 0.12, 0.22, 0.35, 0.5, 0.65, 0.78, 0.92, 1], Extrapolation.CLAMP ); // Ultra-subtle slide effect with smooth ease-out curve const slideDistance = 6; // Even more subtle 6px movement const slideProgress = interpolate( dragProgress.value, [0, 0.2, 0.4, 0.6, 0.8, 1], // 6-point for ultra-smooth acceleration [ -slideDistance * dragDirection.value, -slideDistance * 0.8 * dragDirection.value, -slideDistance * 0.6 * dragDirection.value, -slideDistance * 0.35 * dragDirection.value, -slideDistance * 0.12 * dragDirection.value, 0 ], Extrapolation.CLAMP ); return { opacity, transform: [{ translateX: slideProgress }], }; }); // Animated style for logo/title only - fades during drag with smoother curve const logoAnimatedStyle = useAnimatedStyle(() => { const dragFade = interpolate( dragProgress.value, [0, 0.2, 0.4], [1, 0.5, 0], Extrapolation.CLAMP ); return { opacity: dragFade * logoOpacity.value, }; }); // Animated style for hero container - smooth fade-in on load const heroContainerStyle = useAnimatedStyle(() => { return { opacity: heroOpacity.value, }; }); const handleDotPress = useCallback((index: number) => { lastInteractionRef.current = Date.now(); setCurrentIndex(index); }, []); if (loading && !currentItem) { return ( ); } if (!currentItem || items.length === 0) { return ( {t('home.no_featured_available')} {onRetry && ( {t('home.retry')} )} ); } const bannerUrl = currentItem.banner || currentItem.poster; const nextItem = items[nextIndex]; const nextBannerUrl = nextItem ? (nextItem.banner || nextItem.poster) : bannerUrl; return ( {/* Background Images with Crossfade */} {/* Current Image - Always visible as base */} setBannerLoaded((prev) => ({ ...prev, [currentIndex]: true }))} /> {/* Next/Preview Image - Animated overlay during drag */} {nextIndex !== currentIndex && ( setBannerLoaded((prev) => ({ ...prev, [nextIndex]: true }))} /> )} {/* Hidden preload trailer player */} {settings?.showTrailers && trailerSource?.videoUrl && !trailerLoading && !trailerError && !trailerPreloaded && ( )} {/* Visible trailer player - 60% height with 5% zoom and smooth fade */} {settings?.showTrailers && trailerSource?.videoUrl && !trailerLoading && !trailerError && trailerPreloaded && ( { if (status.isLoaded && !trailerReady) { handleTrailerReady(); } }} /> {/* Gradient blend at bottom of trailer */} )} {/* Gradient Overlay - darker at bottom for text readability */} {/* Trailer control buttons (unmute and fullscreen) */} {settings?.showTrailers && trailerReady && trailerSource?.videoUrl && ( {/* Fullscreen button */} { e?.stopPropagation(); handleFullscreenToggle(); }} activeOpacity={0.7} onPressIn={(e) => e?.stopPropagation()} onPressOut={(e) => e?.stopPropagation()} style={{ padding: 8, backgroundColor: 'rgba(0, 0, 0, 0.6)', borderRadius: 20, }} > {/* Unmute button */} { e?.stopPropagation(); handleMuteToggle(); }} activeOpacity={0.7} onPressIn={(e) => e?.stopPropagation()} onPressOut={(e) => e?.stopPropagation()} style={{ padding: 8, backgroundColor: 'rgba(0, 0, 0, 0.6)', borderRadius: 20, }} > )} {/* Content Overlay */} {/* Logo or Title with Fade Animation */} {currentItem.logo && !logoError[currentIndex] ? ( { if (currentItem) { navigation.navigate('Metadata', { id: currentItem.id, type: currentItem.type, addonId: currentItem.addonId, }); } }} > { const { height } = event.nativeEvent.layout; setLogoHeights((prev) => ({ ...prev, [currentIndex]: height })); }} > setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))} onError={() => { setLogoError((prev) => ({ ...prev, [currentIndex]: true })); logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo); }} /> ) : ( { if (currentItem) { navigation.navigate('Metadata', { id: currentItem.id, type: currentItem.type, addonId: currentItem.addonId, }); } }} > {currentItem.name} )} {/* Metadata Badge - Always Visible */} {currentItem.type === 'series' ? t('home.tv_show') : t('home.movie')} {currentItem.genres && currentItem.genres.length > 0 && ( <> {currentItem.genres[0]} )} {/* Action Buttons - Play and Save buttons */} {/* Play Button */} {shouldResume ? ( ) : ( )} {shouldResume ? t('home.resume') : t('home.play')} {/* Save Button */} {Platform.OS === 'ios' ? ( ) : ( )} {/* Pagination Dots */} {items.length > 1 && ( {items.map((_, index) => ( handleDotPress(index)} /> ))} )} {/* Bottom blend to home screen background */} ); }; const styles = StyleSheet.create({ container: { width: '100%', position: 'relative', marginBottom: 0, // Remove margin to go full height overflow: 'hidden', // Ensure trailer stays within bounds }, backgroundContainer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 1, }, imageWrapper: { position: 'absolute', top: 0, left: -50, // Extend 50px to left right: -50, // Extend 50px to right bottom: 0, }, imageWrapperAbsolute: { position: 'absolute', top: 0, left: -50, // Extend 50px to left right: -50, // Extend 50px to right bottom: 0, }, backgroundImage: { width: '100%', height: '100%', }, gradientOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 10, }, contentContainer: { flex: 1, justifyContent: 'flex-end', alignItems: 'center', paddingHorizontal: 24, zIndex: 20, // Above background and trailer // paddingBottom will be set dynamically with insets }, logoContainer: { width: width * 0.6, height: 100, alignItems: 'center', justifyContent: 'center', }, logo: { width: '100%', height: '100%', }, titleContainer: { marginBottom: 8, alignItems: 'center', justifyContent: 'center', }, title: { fontSize: 36, fontWeight: '900', color: '#fff', textAlign: 'center', textShadowColor: 'rgba(0,0,0,0.8)', textShadowOffset: { width: 0, height: 2 }, textShadowRadius: 4, }, metadataContainer: { marginBottom: 20, alignItems: 'center', justifyContent: 'center', }, metadataBadge: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, }, metadataText: { color: '#fff', fontSize: 14, fontWeight: '600', }, metadataDot: { color: 'rgba(255,255,255,0.6)', fontSize: 14, }, buttonsContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, marginBottom: 20, }, playButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: '#fff', paddingVertical: 11, paddingHorizontal: 32, borderRadius: 40, gap: 8, minWidth: 130, }, playButtonText: { color: '#000', fontSize: 18, fontWeight: '700', }, saveButton: { width: 52, height: 52, borderRadius: 30, backgroundColor: 'rgba(255,255,255,0.2)', overflow: 'hidden', alignItems: 'center', justifyContent: 'center', borderWidth: 1.5, borderColor: 'rgba(255,255,255,0.3)', }, saveButtonBlur: { width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center', }, paginationContainer: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 12, }, paginationDot: { height: 8, borderRadius: 4, backgroundColor: 'rgba(255,255,255,0.9)', }, bottomBlend: { position: 'absolute', bottom: 0, left: 0, right: 0, height: 400, // Increased to cover action buttons with dark background pointerEvents: 'none', }, // Loading & Empty States skeletonContainer: { flex: 1, backgroundColor: 'rgba(255,255,255,0.05)', }, noContentContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, }, noContentText: { fontSize: 16, color: 'rgba(255,255,255,0.7)', textAlign: 'center', }, retryButton: { backgroundColor: 'rgba(255,255,255,0.2)', paddingVertical: 12, paddingHorizontal: 24, borderRadius: 8, marginTop: 8, }, retryButtonText: { color: '#fff', fontSize: 16, fontWeight: '600', }, }); export default React.memo(AppleTVHero);