From 1c083f836b5198cdaf0c1287135ece83393241c3 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 8 Nov 2025 02:07:31 +0530 Subject: [PATCH] changes --- src/components/home/AppleTVHero.tsx | 633 +++++++++++++++++++++ src/components/home/ContentItem.tsx | 2 +- src/components/metadata/FloatingHeader.tsx | 127 ++--- src/hooks/useSettings.ts | 2 +- src/screens/HomeScreen.tsx | 62 +- src/screens/HomeScreenSettings.tsx | 8 +- src/screens/LibraryScreen.tsx | 6 +- 7 files changed, 724 insertions(+), 116 deletions(-) create mode 100644 src/components/home/AppleTVHero.tsx diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx new file mode 100644 index 00000000..59a28b1f --- /dev/null +++ b/src/components/home/AppleTVHero.tsx @@ -0,0 +1,633 @@ +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Dimensions, + Platform, + ViewStyle, + TextStyle, + StatusBar, +} from 'react-native'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { RootStackParamList } from '../../navigation/AppNavigator'; +import { LinearGradient } from 'expo-linear-gradient'; +import FastImage from '@d11/react-native-fast-image'; +import { MaterialIcons } from '@expo/vector-icons'; +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + useSharedValue, + withTiming, + Easing, + withDelay, + runOnJS, + interpolate, + Extrapolate, +} 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'; + +interface AppleTVHeroProps { + featuredContent: StreamingContent | null; + allFeaturedContent?: StreamingContent[]; + isSaved: boolean; + handleSaveToLibrary: () => void; + loading?: boolean; + onRetry?: () => void; +} + +const { width, height } = Dimensions.get('window'); + +// Get status bar height +const STATUS_BAR_HEIGHT = StatusBar.currentHeight || 0; + +// Calculate hero height - 65% of screen height +const HERO_HEIGHT = height * 0.75; + +const AppleTVHero: React.FC = ({ + featuredContent, + allFeaturedContent, + isSaved, + handleSaveToLibrary, + loading, + onRetry, +}) => { + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + + // 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 autoPlayTimerRef = useRef(null); + const lastInteractionRef = useRef(Date.now()); + + const currentItem = items[currentIndex] || null; + + // Animation values + const dragProgress = useSharedValue(0); + const logoOpacity = useSharedValue(1); + const [nextIndex, setNextIndex] = useState(currentIndex); + + // Reset loaded states when items change + useEffect(() => { + setBannerLoaded({}); + setLogoLoaded({}); + setLogoError({}); + }, [items.length]); + + // Auto-advance timer + const startAutoPlay = useCallback(() => { + if (autoPlayTimerRef.current) { + clearTimeout(autoPlayTimerRef.current); + } + + if (items.length <= 1) return; + + autoPlayTimerRef.current = setTimeout(() => { + const timeSinceInteraction = Date.now() - lastInteractionRef.current; + // Only auto-advance if user hasn't interacted recently (5 seconds) + if (timeSinceInteraction >= 5000) { + setCurrentIndex((prev) => (prev + 1) % items.length); + } else { + // Retry after remaining time + startAutoPlay(); + } + }, 5000); // Auto-advance every 5 seconds + }, [items.length]); + + useEffect(() => { + startAutoPlay(); + return () => { + if (autoPlayTimerRef.current) { + clearTimeout(autoPlayTimerRef.current); + } + }; + }, [startAutoPlay, currentIndex]); + + // Reset drag progress and animate logo when index changes + useEffect(() => { + dragProgress.value = 0; + setNextIndex(currentIndex); + + // Fade out and fade in logo/title + logoOpacity.value = 0; + logoOpacity.value = withDelay( + 200, + withTiming(1, { + duration: 400, + easing: Easing.out(Easing.cubic), + }) + ); + }, [currentIndex]); + + // 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); + }, []); + + // Swipe gesture handler with live preview + const panGesture = useMemo( + () => + Gesture.Pan() + .onStart(() => { + // Determine which direction and set preview + runOnJS(updateInteractionTime)(); + }) + .onUpdate((event) => { + const translationX = event.translationX; + const progress = Math.abs(translationX) / width; + + // Update drag progress (0 to 1) + dragProgress.value = Math.min(progress, 1); + + // 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.25; + + if (Math.abs(translationX) > swipeThreshold || Math.abs(velocity) > 800) { + // Complete the swipe + if (translationX > 0) { + runOnJS(goToPrevious)(); + } else { + runOnJS(goToNext)(); + } + } else { + // Cancel the swipe - animate back + dragProgress.value = withTiming(0, { + duration: 300, + easing: Easing.out(Easing.cubic), + }); + } + }), + [goToPrevious, goToNext, updateInteractionTime, setPreviewIndex, currentIndex, items.length] + ); + + // Animated styles for current and next images + const currentImageStyle = useAnimatedStyle(() => { + return { + opacity: interpolate( + dragProgress.value, + [0, 1], + [1, 0], + Extrapolate.CLAMP + ), + }; + }); + + const nextImageStyle = useAnimatedStyle(() => { + return { + opacity: interpolate( + dragProgress.value, + [0, 1], + [0, 1], + Extrapolate.CLAMP + ), + }; + }); + + // Animated style for logo/title only - fades during drag + const logoAnimatedStyle = useAnimatedStyle(() => { + const dragFade = interpolate( + dragProgress.value, + [0, 0.3], + [1, 0], + Extrapolate.CLAMP + ); + + return { + opacity: dragFade * logoOpacity.value, + }; + }); + + const handleDotPress = useCallback((index: number) => { + lastInteractionRef.current = Date.now(); + setCurrentIndex(index); + }, []); + + if (loading) { + return ( + + + + + + ); + } + + if (!currentItem || items.length === 0) { + return ( + + + + No featured content available + {onRetry && ( + + 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 */} + + setBannerLoaded((prev) => ({ ...prev, [currentIndex]: true }))} + /> + + + {/* Next/Preview Image */} + {nextIndex !== currentIndex && ( + + setBannerLoaded((prev) => ({ ...prev, [nextIndex]: true }))} + /> + + )} + + {/* Gradient Overlay - darker at bottom for text readability */} + + + + {/* Content Overlay */} + + {/* Logo or Title with Fade Animation */} + + {currentItem.logo && !logoError[currentIndex] ? ( + + setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))} + onError={() => { + setLogoError((prev) => ({ ...prev, [currentIndex]: true })); + logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo); + }} + /> + + ) : ( + + + {currentItem.name} + + + )} + + + {/* Metadata Badge - Always Visible */} + + + + + {currentItem.type === 'series' ? 'TV Show' : 'Movie'} + + {currentItem.genres && currentItem.genres.length > 0 && ( + <> + + {currentItem.genres[0]} + + )} + {currentItem.certification && ( + + {currentItem.certification} + + )} + + + + {/* Action Buttons - Always Visible */} + + {/* Play Button */} + { + navigation.navigate('Streams', { + id: currentItem.id, + type: currentItem.type, + }); + }} + activeOpacity={0.8} + > + + Play + + + {/* Add to List Button */} + + + + + + {/* Pagination Dots */} + {items.length > 1 && ( + + {items.map((_, index) => ( + handleDotPress(index)} + activeOpacity={0.7} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + ))} + + )} + + + {/* Bottom blend to home screen background */} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + position: 'relative', + marginBottom: 0, // Remove margin to go full height + }, + backgroundContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + backgroundImage: { + width: '100%', + height: '100%', + }, + gradientOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + contentContainer: { + flex: 1, + justifyContent: 'flex-end', + alignItems: 'center', + paddingHorizontal: 24, + // paddingBottom will be set dynamically with insets + }, + logoContainer: { + width: width * 0.6, + height: 120, + marginBottom: 20, + alignItems: 'center', + justifyContent: 'center', + }, + logo: { + width: '100%', + height: '100%', + }, + titleContainer: { + marginBottom: 20, + 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: 24, + alignItems: 'center', + justifyContent: 'center', + }, + metadataBadge: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.15)', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + gap: 8, + }, + metadataText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + metadataDot: { + color: 'rgba(255,255,255,0.6)', + fontSize: 14, + }, + ratingBadge: { + backgroundColor: 'rgba(255,255,255,0.25)', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 4, + marginLeft: 4, + }, + ratingText: { + color: '#fff', + fontSize: 12, + fontWeight: '700', + }, + buttonsContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + marginBottom: 24, + }, + playButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#fff', + paddingVertical: 14, + paddingHorizontal: 32, + borderRadius: 8, + gap: 8, + minWidth: 140, + }, + playButtonText: { + color: '#000', + fontSize: 18, + fontWeight: '700', + }, + secondaryButton: { + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: 'rgba(255,255,255,0.2)', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.3)', + }, + paginationContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + paginationDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: 'rgba(255,255,255,0.3)', + }, + paginationDotActive: { + width: 32, + backgroundColor: 'rgba(255,255,255,0.9)', + }, + bottomBlend: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 60, + 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); diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 6787a2ec..05fa6342 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -349,7 +349,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe style={[ styles.title, { - color: currentTheme.colors.text, + color: currentTheme.colors.mediumEmphasis, fontSize: getDeviceType(width) === 'tv' ? 16 : getDeviceType(width) === 'largeTablet' ? 15 : getDeviceType(width) === 'tablet' ? 14 : 13 } ]} diff --git a/src/components/metadata/FloatingHeader.tsx b/src/components/metadata/FloatingHeader.tsx index ca7ffdd0..1f8cc80a 100644 --- a/src/components/metadata/FloatingHeader.tsx +++ b/src/components/metadata/FloatingHeader.tsx @@ -94,94 +94,49 @@ const FloatingHeader: React.FC = ({ return ( {Platform.OS === 'ios' ? ( - GlassViewComp && liquidGlassAvailable ? ( - - - - + + + + + + + {(stableLogoUri || metadata.logo) && !logoLoadError ? ( + { + logger.warn(`[FloatingHeader] Logo failed to load: ${stableLogoUri || metadata.logo}`); + setLogoLoadError(true); + }} /> - + ) : ( + {metadata.name} + )} + - - {(stableLogoUri || metadata.logo) && !logoLoadError ? ( - { - logger.warn(`[FloatingHeader] Logo failed to load: ${stableLogoUri || metadata.logo}`); - setLogoLoadError(true); - }} - /> - ) : ( - {metadata.name} - )} - - - - - - - - ) : ( - - - - - - - - {(stableLogoUri || metadata.logo) && !logoLoadError ? ( - { - logger.warn(`[FloatingHeader] Logo failed to load: ${stableLogoUri || metadata.logo}`); - setLogoLoadError(true); - }} - /> - ) : ( - {metadata.name} - )} - - - - - - - - ) + + + + + ) : ( { // Memoize individual section components to prevent re-renders const memoizedFeaturedContent = useMemo(() => { const heroStyleToUse = settings.heroStyle; - return heroStyleToUse === 'carousel' ? ( - - ) : ( - <> - + ); + } else if (heroStyleToUse === 'appletv') { + return ( + - - - ); - }, [isTablet, settings.heroStyle, showHeroSection, featuredContentSource, allFeaturedContent, featuredContent, isItemSaved, handleSaveToLibrary, featuredLoading]); + ); + } else { + return ( + <> + + + + ); + } + }, [isTablet, settings.heroStyle, showHeroSection, featuredContentSource, allFeaturedContent, featuredContent, isSaved, handleSaveToLibrary, featuredLoading]); const memoizedThisWeekSection = useMemo(() => , []); const memoizedContinueWatchingSection = useMemo(() => , []); diff --git a/src/screens/HomeScreenSettings.tsx b/src/screens/HomeScreenSettings.tsx index 77e5fb48..3bfb199c 100644 --- a/src/screens/HomeScreenSettings.tsx +++ b/src/screens/HomeScreenSettings.tsx @@ -342,11 +342,15 @@ const HomeScreenSettings: React.FC = () => { Hero Layout handleUpdateSetting('heroStyle', val as any)} /> - Full-width banner or swipeable cards + Full-width banner, swipeable cards, or Apple TV style diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index d5aa50fc..3f7d6e6b 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -128,7 +128,7 @@ const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item: )} - + {item.name} @@ -425,7 +425,7 @@ const LibraryScreen = () => { )} - + {item.name} @@ -489,7 +489,7 @@ const LibraryScreen = () => { )} - + Trakt collections