527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
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 { LinearGradient } from 'expo-linear-gradient';
|
|
import { BlurView } from 'expo-blur';
|
|
import FastImage from '@d11/react-native-fast-image';
|
|
import { MaterialIcons } from '@expo/vector-icons';
|
|
import { useNavigation } from '@react-navigation/native';
|
|
import { NavigationProp } from '@react-navigation/native';
|
|
import { RootStackParamList } from '../../navigation/AppNavigator';
|
|
import { StreamingContent } from '../../services/catalogService';
|
|
import { useTheme } from '../../contexts/ThemeContext';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useSettings } from '../../hooks/useSettings';
|
|
|
|
interface HeroCarouselProps {
|
|
items: StreamingContent[];
|
|
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
|
|
|
|
const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) => {
|
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
|
const { currentTheme } = useTheme();
|
|
const insets = useSafeAreaInsets();
|
|
const { settings } = useSettings();
|
|
|
|
const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]);
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
const [failedLogoIds, setFailedLogoIds] = useState<Set<string>>(new Set());
|
|
|
|
// Note: do not early-return before hooks. Loading UI is returned later.
|
|
|
|
const hasData = data.length > 0;
|
|
|
|
// Optimized: update background as soon as scroll starts, without waiting for momentum end
|
|
const scrollX = useSharedValue(0);
|
|
const interval = CARD_WIDTH + 16;
|
|
const scrollHandler = useAnimatedScrollHandler({
|
|
onScroll: (event) => {
|
|
scrollX.value = event.contentOffset.x;
|
|
},
|
|
});
|
|
|
|
// Derive the index reactively and only set state when it changes
|
|
useAnimatedReaction(
|
|
() => {
|
|
const idx = Math.round(scrollX.value / interval);
|
|
return idx;
|
|
},
|
|
(idx, prevIdx) => {
|
|
if (idx == null || idx === prevIdx) return;
|
|
// Clamp to bounds to avoid out-of-range access
|
|
const clamped = Math.max(0, Math.min(idx, data.length - 1));
|
|
runOnJS(setActiveIndex)(clamped);
|
|
},
|
|
[data.length]
|
|
);
|
|
|
|
const contentPadding = useMemo(() => ({ paddingHorizontal: (width - CARD_WIDTH) / 2 }), []);
|
|
|
|
const keyExtractor = useCallback((item: StreamingContent) => item.id, []);
|
|
|
|
const getItemLayout = useCallback(
|
|
(_: unknown, index: number) => {
|
|
const length = CARD_WIDTH + 16;
|
|
const offset = length * index;
|
|
return { length, offset, index };
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleNavigateToMetadata = useCallback((id: string, type: any) => {
|
|
navigation.navigate('Metadata', { id, type });
|
|
}, [navigation]);
|
|
|
|
const handleNavigateToStreams = useCallback((id: string, type: any) => {
|
|
navigation.navigate('Streams', { id, type });
|
|
}, [navigation]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<View style={[styles.container, { paddingVertical: 12 }] as StyleProp<ViewStyle>}>
|
|
<View style={{ height: CARD_HEIGHT }}>
|
|
<FlatList
|
|
data={[1, 2, 3] as any}
|
|
keyExtractor={(i) => String(i)}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
snapToInterval={CARD_WIDTH + 16}
|
|
decelerationRate="fast"
|
|
contentContainerStyle={{ paddingHorizontal: (width - CARD_WIDTH) / 2 }}
|
|
renderItem={() => (
|
|
<View style={{ width: CARD_WIDTH + 16 }}>
|
|
<View style={[
|
|
styles.card,
|
|
{
|
|
backgroundColor: currentTheme.colors.elevation1,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.18)',
|
|
}
|
|
] as StyleProp<ViewStyle>}>
|
|
<View style={styles.bannerContainer as ViewStyle}>
|
|
<View style={styles.skeletonBannerFull as ViewStyle} />
|
|
<LinearGradient
|
|
colors={["transparent", "rgba(0,0,0,0.25)"]}
|
|
locations={[0.6, 1]}
|
|
style={styles.bannerOverlay as ViewStyle}
|
|
/>
|
|
</View>
|
|
<View style={styles.info as ViewStyle}>
|
|
<View style={[styles.skeletonLine, { width: '62%' }] as StyleProp<ViewStyle>} />
|
|
<View style={[styles.skeletonLine, { width: '44%', marginTop: 6 }] as StyleProp<ViewStyle>} />
|
|
<View style={styles.skeletonActions as ViewStyle}>
|
|
<View style={[styles.skeletonPill, { width: 96 }] as StyleProp<ViewStyle>} />
|
|
<View style={[styles.skeletonPill, { width: 80 }] as StyleProp<ViewStyle>} />
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
)}
|
|
/>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Memoized background component with improved timing
|
|
const BackgroundImage = React.memo(({
|
|
item,
|
|
insets
|
|
}: {
|
|
item: StreamingContent;
|
|
insets: any;
|
|
}) => {
|
|
const animatedOpacity = useSharedValue(1);
|
|
|
|
useEffect(() => {
|
|
// Start with opacity 0 and animate to 1, but only if it's a new item
|
|
animatedOpacity.value = 0;
|
|
animatedOpacity.value = withTiming(1, { duration: 400 });
|
|
}, [item.id]);
|
|
|
|
const animatedStyle = useAnimatedStyle(() => ({
|
|
opacity: animatedOpacity.value,
|
|
}));
|
|
|
|
return (
|
|
<View
|
|
style={[
|
|
styles.backgroundContainer,
|
|
{ top: -insets.top },
|
|
] as StyleProp<ViewStyle>}
|
|
pointerEvents="none"
|
|
>
|
|
<Animated.View
|
|
key={item.id}
|
|
style={[animatedStyle, { flex: 1 }] as any}
|
|
>
|
|
{Platform.OS === 'android' ? (
|
|
<Image
|
|
source={{ uri: item.banner || item.poster }}
|
|
style={styles.backgroundImage as any}
|
|
resizeMode="cover"
|
|
blurRadius={20}
|
|
/>
|
|
) : (
|
|
<>
|
|
<FastImage
|
|
source={{
|
|
uri: item.banner || item.poster,
|
|
priority: FastImage.priority.low,
|
|
cache: FastImage.cacheControl.immutable
|
|
}}
|
|
style={styles.backgroundImage as any}
|
|
resizeMode={FastImage.resizeMode.cover}
|
|
/>
|
|
<BlurView
|
|
style={styles.backgroundImage as any}
|
|
intensity={30}
|
|
tint="dark"
|
|
/>
|
|
</>
|
|
)}
|
|
<LinearGradient
|
|
colors={["rgba(0,0,0,0.45)", "rgba(0,0,0,0.75)"]}
|
|
locations={[0.4, 1]}
|
|
style={styles.backgroundOverlay as ViewStyle}
|
|
/>
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
if (!hasData) return null;
|
|
|
|
return (
|
|
<Animated.View entering={FadeIn.duration(350).easing(Easing.out(Easing.cubic))}>
|
|
<View style={styles.container as ViewStyle}>
|
|
{settings.enableHomeHeroBackground && data.length > 0 && (
|
|
<View style={{ height: 0, width: 0, overflow: 'hidden' }}>
|
|
{data[activeIndex + 1] && (
|
|
<FastImage
|
|
source={{
|
|
uri: data[activeIndex + 1].banner || data[activeIndex + 1].poster,
|
|
priority: FastImage.priority.low,
|
|
cache: FastImage.cacheControl.immutable
|
|
}}
|
|
style={{ width: 1, height: 1 }}
|
|
resizeMode={FastImage.resizeMode.cover}
|
|
/>
|
|
)}
|
|
{activeIndex > 0 && data[activeIndex - 1] && (
|
|
<FastImage
|
|
source={{
|
|
uri: data[activeIndex - 1].banner || data[activeIndex - 1].poster,
|
|
priority: FastImage.priority.low,
|
|
cache: FastImage.cacheControl.immutable
|
|
}}
|
|
style={{ width: 1, height: 1 }}
|
|
resizeMode={FastImage.resizeMode.cover}
|
|
/>
|
|
)}
|
|
</View>
|
|
)}
|
|
{settings.enableHomeHeroBackground && data[activeIndex] && (
|
|
<BackgroundImage
|
|
item={data[activeIndex]}
|
|
insets={insets}
|
|
/>
|
|
)}
|
|
{/* Bottom blend to HomeScreen background (not the card) */}
|
|
{settings.enableHomeHeroBackground && (
|
|
<LinearGradient
|
|
colors={["transparent", currentTheme.colors.darkBackground]}
|
|
locations={[0, 1]}
|
|
style={styles.bottomBlend as ViewStyle}
|
|
pointerEvents="none"
|
|
/>
|
|
)}
|
|
<Animated.FlatList
|
|
data={data}
|
|
keyExtractor={keyExtractor}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
snapToInterval={CARD_WIDTH + 16}
|
|
decelerationRate="fast"
|
|
contentContainerStyle={contentPadding}
|
|
onScroll={scrollHandler}
|
|
scrollEventThrottle={32}
|
|
disableIntervalMomentum
|
|
initialNumToRender={2}
|
|
windowSize={3}
|
|
maxToRenderPerBatch={2}
|
|
updateCellsBatchingPeriod={50}
|
|
removeClippedSubviews
|
|
getItemLayout={getItemLayout}
|
|
renderItem={({ item }) => (
|
|
<View style={{ width: CARD_WIDTH + 16 }}>
|
|
<CarouselCard
|
|
item={item}
|
|
colors={currentTheme.colors}
|
|
logoFailed={failedLogoIds.has(item.id)}
|
|
onLogoError={() => setFailedLogoIds((prev) => new Set(prev).add(item.id))}
|
|
onPressInfo={() => handleNavigateToMetadata(item.id, item.type)}
|
|
onPressPlay={() => handleNavigateToStreams(item.id, item.type)}
|
|
/>
|
|
</View>
|
|
)}
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
interface CarouselCardProps {
|
|
item: StreamingContent;
|
|
colors: any;
|
|
logoFailed: boolean;
|
|
onLogoError: () => void;
|
|
onPressPlay: () => void;
|
|
onPressInfo: () => void;
|
|
}
|
|
|
|
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressPlay, onPressInfo }) => {
|
|
return (
|
|
<TouchableOpacity
|
|
activeOpacity={0.9}
|
|
onPress={onPressInfo}
|
|
>
|
|
<View style={[
|
|
styles.card,
|
|
{
|
|
backgroundColor: colors.elevation1,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.18)',
|
|
}
|
|
] as StyleProp<ViewStyle>}>
|
|
<View style={styles.bannerContainer as ViewStyle}>
|
|
<FastImage
|
|
source={{
|
|
uri: item.banner || item.poster,
|
|
priority: FastImage.priority.normal,
|
|
cache: FastImage.cacheControl.immutable
|
|
}}
|
|
style={styles.banner as any}
|
|
resizeMode={FastImage.resizeMode.cover}
|
|
/>
|
|
<LinearGradient
|
|
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.6)"]}
|
|
locations={[0.4, 0.7, 1]}
|
|
style={styles.bannerGradient as ViewStyle}
|
|
/>
|
|
</View>
|
|
<View style={styles.info as ViewStyle}>
|
|
{item.logo && !logoFailed ? (
|
|
<FastImage
|
|
source={{
|
|
uri: item.logo,
|
|
priority: FastImage.priority.high,
|
|
cache: FastImage.cacheControl.immutable
|
|
}}
|
|
style={styles.logo as any}
|
|
resizeMode={FastImage.resizeMode.contain}
|
|
onError={onLogoError}
|
|
/>
|
|
) : (
|
|
<Text style={[styles.title as TextStyle, { color: colors.highEmphasis, textAlign: 'center' }]} numberOfLines={1}>
|
|
{item.name}
|
|
</Text>
|
|
)}
|
|
{item.genres && (
|
|
<Text style={[styles.genres as TextStyle, { color: colors.mediumEmphasis, textAlign: 'center' }]} numberOfLines={1}>
|
|
{item.genres.slice(0, 3).join(' • ')}
|
|
</Text>
|
|
)}
|
|
<View style={styles.actions as ViewStyle}>
|
|
<TouchableOpacity
|
|
style={[styles.playButton as ViewStyle, { backgroundColor: colors.white }]}
|
|
onPress={onPressPlay}
|
|
activeOpacity={0.85}
|
|
>
|
|
<MaterialIcons name="play-arrow" size={22} color={colors.black} />
|
|
<Text style={[styles.playText as TextStyle, { color: colors.black }]}>Play</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[styles.secondaryButton as ViewStyle, { borderColor: 'rgba(255,255,255,0.25)' }]}
|
|
onPress={onPressInfo}
|
|
activeOpacity={0.8}
|
|
>
|
|
<MaterialIcons name="info-outline" size={18} color={colors.white} />
|
|
<Text style={[styles.secondaryText as TextStyle, { color: colors.white }]}>Info</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
});
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
paddingVertical: 12,
|
|
},
|
|
backgroundContainer: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
},
|
|
backgroundImage: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
},
|
|
backgroundOverlay: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
},
|
|
bottomBlend: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
height: 160,
|
|
},
|
|
card: {
|
|
width: CARD_WIDTH,
|
|
height: CARD_HEIGHT,
|
|
borderRadius: 16,
|
|
overflow: 'hidden',
|
|
elevation: 2,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.15,
|
|
shadowRadius: 4,
|
|
},
|
|
skeletonCard: {
|
|
width: CARD_WIDTH,
|
|
height: CARD_HEIGHT,
|
|
borderRadius: 16,
|
|
overflow: 'hidden',
|
|
},
|
|
skeletonBannerFull: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
backgroundColor: 'rgba(255,255,255,0.06)'
|
|
},
|
|
bannerOverlay: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
},
|
|
skeletonInfo: {
|
|
paddingHorizontal: 16,
|
|
paddingTop: 10,
|
|
},
|
|
skeletonLine: {
|
|
height: 14,
|
|
borderRadius: 7,
|
|
backgroundColor: 'rgba(255,255,255,0.08)'
|
|
},
|
|
skeletonActions: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
marginTop: 12,
|
|
},
|
|
skeletonPill: {
|
|
height: 36,
|
|
borderRadius: 18,
|
|
backgroundColor: 'rgba(255,255,255,0.1)'
|
|
},
|
|
bannerContainer: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
},
|
|
banner: {
|
|
width: '100%',
|
|
height: '100%',
|
|
},
|
|
bannerGradient: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
top: 0,
|
|
},
|
|
|
|
info: {
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
paddingHorizontal: 16,
|
|
paddingTop: 10,
|
|
paddingBottom: 12,
|
|
alignItems: 'center',
|
|
},
|
|
title: {
|
|
fontSize: 18,
|
|
fontWeight: '800',
|
|
},
|
|
genres: {
|
|
marginTop: 2,
|
|
fontSize: 13,
|
|
},
|
|
actions: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
marginTop: 12,
|
|
justifyContent: 'center',
|
|
},
|
|
logo: {
|
|
width: Math.round(CARD_WIDTH * 0.72),
|
|
height: 64,
|
|
marginBottom: 6,
|
|
},
|
|
playButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
borderRadius: 24,
|
|
},
|
|
playText: {
|
|
fontWeight: '700',
|
|
marginLeft: 6,
|
|
fontSize: 14,
|
|
},
|
|
secondaryButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 9,
|
|
borderRadius: 22,
|
|
borderWidth: 1,
|
|
},
|
|
secondaryText: {
|
|
fontWeight: '600',
|
|
marginLeft: 6,
|
|
fontSize: 14,
|
|
},
|
|
});
|
|
|
|
export default React.memo(HeroCarousel);
|
|
|
|
|