diff --git a/src/components/home/HeroCarousel.tsx b/src/components/home/HeroCarousel.tsx index fb7e20c5..a5dc5e3c 100644 --- a/src/components/home/HeroCarousel.tsx +++ b/src/components/home/HeroCarousel.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState, useEffect, useCallback, memo } from 'react'; import { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, FlatList, StyleProp, Platform, Image } from 'react-native'; -import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle, useAnimatedScrollHandler, useAnimatedReaction, runOnJS } from 'react-native-reanimated'; +import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle, useAnimatedScrollHandler, useAnimatedReaction, runOnJS, SharedValue } from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; import { BlurView } from 'expo-blur'; import FastImage from '@d11/react-native-fast-image'; @@ -59,6 +59,18 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = onScroll: (event) => { scrollX.value = event.contentOffset.x; }, + onBeginDrag: () => { + // Smooth scroll start - could add haptic feedback here + }, + onEndDrag: () => { + // Smooth scroll end + }, + onMomentumBegin: () => { + // Momentum scroll start + }, + onMomentumEnd: () => { + // Momentum scroll end + }, }); // Derive the index reactively and only set state when it changes @@ -97,6 +109,20 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = navigation.navigate('Streams', { id, type }); }, [navigation]); + // Container animation based on scroll - must be before early returns + const containerAnimatedStyle = useAnimatedStyle(() => { + const translateX = scrollX.value; + const progress = Math.abs(translateX) / (data.length * (CARD_WIDTH + 16)); + + // Subtle scale animation for the entire container + const scale = 1 - progress * 0.02; + const clampedScale = Math.max(0.98, Math.min(1, scale)); + + return { + transform: [{ scale: clampedScale }], + }; + }); + if (loading) { return ( }> @@ -222,7 +248,7 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = return ( - + {settings.enableHomeHeroBackground && data.length > 0 && ( {data[activeIndex + 1] && ( @@ -273,16 +299,19 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = decelerationRate="fast" contentContainerStyle={contentPadding} onScroll={scrollHandler} - scrollEventThrottle={32} + scrollEventThrottle={16} disableIntervalMomentum - initialNumToRender={2} - windowSize={3} - maxToRenderPerBatch={2} - updateCellsBatchingPeriod={50} - removeClippedSubviews + initialNumToRender={3} + windowSize={5} + maxToRenderPerBatch={3} + updateCellsBatchingPeriod={100} + removeClippedSubviews={false} getItemLayout={getItemLayout} - renderItem={({ item }) => ( - + renderItem={({ item, index }) => ( + = ({ items, loading = false }) = onLogoError={() => setFailedLogoIds((prev) => new Set(prev).add(item.id))} onPressInfo={() => handleNavigateToMetadata(item.id, item.type)} onPressPlay={() => handleNavigateToStreams(item.id, item.type)} + scrollX={scrollX} + index={index} /> - + )} /> - + ); }; @@ -306,16 +337,98 @@ interface CarouselCardProps { onLogoError: () => void; onPressPlay: () => void; onPressInfo: () => void; + scrollX: SharedValue; + index: number; } -const CarouselCard: React.FC = memo(({ item, colors, logoFailed, onLogoError, onPressPlay, onPressInfo }) => { +const CarouselCard: React.FC = memo(({ item, colors, logoFailed, onLogoError, onPressPlay, onPressInfo, scrollX, index }) => { + const [bannerLoaded, setBannerLoaded] = useState(false); + const [logoLoaded, setLogoLoaded] = useState(false); + + const bannerOpacity = useSharedValue(0); + const logoOpacity = useSharedValue(0); + + const inputRange = [ + (index - 1) * (CARD_WIDTH + 16), + index * (CARD_WIDTH + 16), + (index + 1) * (CARD_WIDTH + 16), + ]; + + const bannerAnimatedStyle = useAnimatedStyle(() => ({ + opacity: bannerOpacity.value, + })); + + const logoAnimatedStyle = useAnimatedStyle(() => ({ + opacity: logoOpacity.value, + })); + + // Scroll-based animations + const cardAnimatedStyle = useAnimatedStyle(() => { + const translateX = scrollX.value; + const cardOffset = index * (CARD_WIDTH + 16); + const distance = Math.abs(translateX - cardOffset); + const maxDistance = CARD_WIDTH + 16; + + // Scale animation based on distance from center + const scale = 1 - (distance / maxDistance) * 0.1; + const clampedScale = Math.max(0.9, Math.min(1, scale)); + + // Opacity animation for cards that are far from center + const opacity = 1 - (distance / maxDistance) * 0.3; + const clampedOpacity = Math.max(0.7, Math.min(1, opacity)); + + return { + transform: [{ scale: clampedScale }], + opacity: clampedOpacity, + }; + }); + + const bannerParallaxStyle = useAnimatedStyle(() => { + const translateX = scrollX.value; + const cardOffset = index * (CARD_WIDTH + 16); + const distance = translateX - cardOffset; + + // Subtle parallax effect for banner + const parallaxOffset = distance * 0.1; + + return { + transform: [{ translateX: parallaxOffset }], + }; + }); + + const infoParallaxStyle = useAnimatedStyle(() => { + const translateX = scrollX.value; + const cardOffset = index * (CARD_WIDTH + 16); + const distance = translateX - cardOffset; + + // Reverse parallax for info section + const parallaxOffset = -distance * 0.05; + + return { + transform: [{ translateY: parallaxOffset }], + }; + }); + + useEffect(() => { + if (bannerLoaded) { + bannerOpacity.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.cubic) }); + } + }, [bannerLoaded]); + + useEffect(() => { + if (logoLoaded) { + logoOpacity.value = withTiming(1, { duration: 400, easing: Easing.out(Easing.cubic) }); + } + }, [logoLoaded]); + return ( - = memo(({ item, colors, logoFail } ] as StyleProp}> - + {!bannerLoaded && ( + + )} + + setBannerLoaded(true)} + /> + - + {item.logo && !logoFailed ? ( - + + setLogoLoaded(true)} + onError={onLogoError} + /> + ) : ( - - {item.name} - + + + {item.name} + + )} {item.genres && ( - + {item.genres.slice(0, 3).join(' • ')} - + )} - + = memo(({ item, colors, logoFail Info - - - + + + ); });