diff --git a/src/components/home/HeroCarousel.tsx b/src/components/home/HeroCarousel.tsx index 773b6a13..4a5f3c9f 100644 --- a/src/components/home/HeroCarousel.tsx +++ b/src/components/home/HeroCarousel.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState, useEffect, useCallback, memo, useRef } from 'react'; -import { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, ScrollView, StyleProp, Platform, Image } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, ScrollView, StyleProp, Platform, Image, useWindowDimensions } from 'react-native'; import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle, useAnimatedScrollHandler, useAnimatedReaction, runOnJS, SharedValue, interpolate, Extrapolation } from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; import { BlurView } from 'expo-blur'; @@ -34,16 +34,39 @@ interface HeroCarouselProps { loading?: boolean; } -const { width } = Dimensions.get('window'); - -const CARD_WIDTH = Math.min(width * 0.8, 480); -const CARD_HEIGHT = Math.round(CARD_WIDTH * 9 / 16) + 310; // increased for more vertical space +// Offset to keep cards below a top tab navigator +const TOP_TABS_OFFSET = Platform.OS === 'ios' ? 44 : 48; const HeroCarousel: React.FC = ({ items, loading = false }) => { const navigation = useNavigation>(); const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); const { settings } = useSettings(); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + + // Responsive sizing computed per-render so rotation updates layout + const isTablet = useMemo( + () => Math.min(windowWidth, windowHeight) >= 600 || (Platform.OS === 'ios' && (Platform as any).isPad), + [windowWidth, windowHeight] + ); + + // Keep height based on baseline phone width; widen only on tablets + const baseCardWidthForHeight = useMemo( + () => Math.min(windowWidth * 0.8, 480), + [windowWidth] + ); + + const cardWidth = useMemo( + () => (isTablet ? Math.max(560, windowWidth - 2 * Math.round(0.1 * windowWidth)) : Math.min(windowWidth * 0.8, 480)), + [isTablet, windowWidth] + ); + + const cardHeight = useMemo( + () => Math.round(baseCardWidthForHeight * 9 / 16) + 310, + [baseCardWidthForHeight] + ); + + const interval = useMemo(() => cardWidth + 16, [cardWidth]); const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]); const loopingEnabled = data.length > 1; @@ -68,7 +91,6 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = // Optimized: update background as soon as scroll starts, without waiting for momentum end const scrollX = useSharedValue(0); - const interval = CARD_WIDTH + 16; const paginationProgress = useSharedValue(0); // Parallel image prefetch: start fetching banners and logos as soon as data arrives @@ -124,6 +146,15 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = return () => clearTimeout(timer); } }, [data.length]); + + // Re-center on rotation using current interval and activeIndex + useEffect(() => { + if (!hasData) return; + const timer = setTimeout(() => { + scrollToLogicalIndex(activeIndex, false); + }, 50); + return () => clearTimeout(timer); + }, [windowWidth, windowHeight, interval, loopingEnabled]); const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { @@ -178,7 +209,7 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = // Align pagination progress with logical index space paginationProgress.value = loopingEnabled ? val - 1 : val; }, - [] + [interval, loopingEnabled] ); // JS helper to jump without flicker when hitting clones @@ -187,7 +218,7 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = scrollViewRef.current?.scrollTo({ x: target, y: 0, animated }); }, [interval, loopingEnabled]); - const contentPadding = useMemo(() => ({ paddingHorizontal: (width - CARD_WIDTH) / 2 }), []); + const contentPadding = useMemo(() => ({ paddingHorizontal: (windowWidth - cardWidth) / 2 }), [windowWidth, cardWidth]); const handleNavigateToMetadata = useCallback((id: string, type: any) => { navigation.navigate('Metadata', { id, type }); @@ -211,20 +242,22 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = if (loading) { return ( }> - + {[1, 2, 3].map((_, index) => ( - + }> @@ -318,7 +351,7 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = return ( - + {/* Removed preload images for performance - let FastImage cache handle it naturally */} {settings.enableHomeHeroBackground && data[activeIndex] && ( = ({ items, loading = false }) = ref={scrollViewRef} horizontal showsHorizontalScrollIndicator={false} - snapToInterval={CARD_WIDTH + 16} + snapToInterval={interval} decelerationRate="fast" contentContainerStyle={contentPadding} onScroll={scrollHandler} @@ -375,6 +408,10 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = index={index} flipped={!!flippedMap[item.id]} onToggleFlip={() => toggleFlipById(item.id)} + interval={interval} + cardWidth={cardWidth} + cardHeight={cardHeight} + isTablet={isTablet} /> ))} @@ -428,9 +465,13 @@ interface CarouselCardProps { index: number; flipped: boolean; onToggleFlip: () => void; + interval: number; + cardWidth: number; + cardHeight: number; + isTablet: boolean; } -const CarouselCard: React.FC = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index, flipped, onToggleFlip }) => { +const CarouselCard: React.FC = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index, flipped, onToggleFlip, interval, cardWidth, cardHeight, isTablet }) => { const [bannerLoaded, setBannerLoaded] = useState(false); const [logoLoaded, setLogoLoaded] = useState(false); @@ -452,9 +493,9 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail }, [item.id]); const inputRange = [ - (index - 1) * (CARD_WIDTH + 16), - index * (CARD_WIDTH + 16), - (index + 1) * (CARD_WIDTH + 16), + (index - 1) * interval, + index * interval, + (index + 1) * interval, ]; const bannerAnimatedStyle = useAnimatedStyle(() => ({ @@ -502,15 +543,15 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail // Combined animation for genres and actions (same calculation) const overlayAnimatedStyle = useAnimatedStyle(() => { const translateX = scrollX.value; - const cardOffset = index * (CARD_WIDTH + 16); + const cardOffset = index * interval; const distance = Math.abs(translateX - cardOffset); // AGGRESSIVE early exit for cards far from center - if (distance > (CARD_WIDTH + 16) * 1.2) { + if (distance > interval * 1.2) { return { opacity: 0 }; } - const maxDistance = (CARD_WIDTH + 16) * 0.5; + const maxDistance = interval * 0.5; const progress = Math.min(distance / maxDistance, 1); const opacity = 1 - progress; const clampedOpacity = Math.max(0, Math.min(1, opacity)); @@ -523,18 +564,18 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail // ULTRA-OPTIMIZED: Only animate center card and ±1 neighbors const cardAnimatedStyle = useAnimatedStyle(() => { const translateX = scrollX.value; - const cardOffset = index * (CARD_WIDTH + 16); + const cardOffset = index * interval; const distance = Math.abs(translateX - cardOffset); // AGGRESSIVE early exit for cards far from center - if (distance > (CARD_WIDTH + 16) * 1.5) { + if (distance > interval * 1.5) { return { transform: [{ scale: 0.9 }], opacity: 0.7 }; } - const maxDistance = CARD_WIDTH + 16; + const maxDistance = interval; // Scale animation based on distance from center const scale = 1 - (distance / maxDistance) * 0.1; @@ -604,8 +645,8 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail }, [logoLoaded]); return ( - - + + = memo(({ item, colors, logoFail backgroundColor: colors.elevation1, borderWidth: 1, borderColor: 'rgba(255,255,255,0.18)', + width: cardWidth, + height: cardHeight, } ] as StyleProp}> - {/* FRONT FACE */} - - + {isTablet ? ( + <> {!bannerLoaded && ( @@ -635,103 +677,159 @@ const CarouselCard: React.FC = memo(({ item, colors, logoFail /> - {item.logo && !logoFailed ? ( - - - setLogoLoaded(true)} - onError={onLogoError} + + {item.logo && !logoFailed ? ( + + ) : ( + + {item.name} + + )} + {item.year && ( + + + + {item.year} + + + )} + + + {item.description || 'No description available'} + + + + + ) : ( + <> + {/* FRONT FACE */} + + + + {!bannerLoaded && ( + + )} + + setBannerLoaded(true)} + /> + + - + + {item.logo && !logoFailed ? ( + + + setLogoLoaded(true)} + onError={onLogoError} + /> + + + ) : ( + + + + {item.name} + + + + )} + {item.genres && ( + + + + {item.genres.slice(0, 3).join(' • ')} + + + + )} + + + + {/* BACK FACE */} + + + + - ) : ( - - - + + {item.logo && !logoFailed ? ( + + ) : ( + {item.name} - + )} + {item.year && ( + + + + {item.year} + + + )} + + + {item.description || 'No description available'} + + - )} - {item.genres && ( - - - - {item.genres.slice(0, 3).join(' • ')} - - - - )} - - + - {/* BACK FACE */} - - - - - - - {item.logo && !logoFailed ? ( - - ) : ( - - {item.name} - - )} - {item.year && ( - - - - {item.year} - - - )} - - - {item.description || 'No description available'} - - - - - - {/* FLIP BUTTON */} - - - - - + {/* FLIP BUTTON */} + + + + + + + )} @@ -771,8 +869,8 @@ const styles = StyleSheet.create({ height: 160, }, card: { - width: CARD_WIDTH, - height: CARD_HEIGHT, + width: '100%', + height: '100%', borderRadius: 16, overflow: 'hidden', elevation: 2, @@ -797,8 +895,8 @@ const styles = StyleSheet.create({ backfaceVisibility: 'hidden', }, skeletonCard: { - width: CARD_WIDTH, - height: CARD_HEIGHT, + width: '100%', + height: '100%', borderRadius: 16, overflow: 'hidden', }, @@ -898,7 +996,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, logo: { - width: Math.round(CARD_WIDTH * 0.72), + width: 200, height: 64, marginBottom: 6, }, diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index b47b3400..8403449d 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -628,7 +628,7 @@ const HomeScreen = () => { // Memoize individual section components to prevent re-renders const memoizedFeaturedContent = useMemo(() => { - const heroStyleToUse = isTablet ? 'legacy' : settings.heroStyle; + const heroStyleToUse = settings.heroStyle; return heroStyleToUse === 'carousel' ? ( { )} - {settings.showHeroSection && !(Dimensions.get('window').width >= 768) && ( + {settings.showHeroSection && ( <> Hero Layout