mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-10 12:01:55 +00:00
herocarousal optimizations
This commit is contained in:
parent
058ee84a2a
commit
afaca6467f
1 changed files with 131 additions and 91 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useMemo, useState, useEffect } from 'react';
|
import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
|
||||||
import { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, FlatList, StyleProp } from 'react-native';
|
import { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, FlatList, StyleProp } from 'react-native';
|
||||||
import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle } from 'react-native-reanimated';
|
import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle } from 'react-native-reanimated';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
@ -30,6 +30,44 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [failedLogoIds, setFailedLogoIds] = useState<Set<string>>(new Set());
|
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;
|
||||||
|
|
||||||
|
const handleMomentumEnd = useCallback((event: any) => {
|
||||||
|
const offsetX = event?.nativeEvent?.contentOffset?.x ?? 0;
|
||||||
|
const interval = CARD_WIDTH + 16;
|
||||||
|
const idx = Math.round(offsetX / interval);
|
||||||
|
const clamped = Math.max(0, Math.min(idx, data.length - 1));
|
||||||
|
if (clamped !== activeIndex) {
|
||||||
|
// Small delay to ensure smooth transition
|
||||||
|
setTimeout(() => {
|
||||||
|
setActiveIndex(clamped);
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}, [activeIndex, 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { paddingVertical: 12 }] as StyleProp<ViewStyle>}>
|
<View style={[styles.container, { paddingVertical: 12 }] as StyleProp<ViewStyle>}>
|
||||||
|
|
@ -70,31 +108,6 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.length === 0) return null;
|
|
||||||
|
|
||||||
const handleMomentumEnd = (event: any) => {
|
|
||||||
const offsetX = event?.nativeEvent?.contentOffset?.x ?? 0;
|
|
||||||
const interval = CARD_WIDTH + 16;
|
|
||||||
const idx = Math.round(offsetX / interval);
|
|
||||||
const clamped = Math.max(0, Math.min(idx, data.length - 1));
|
|
||||||
if (clamped !== activeIndex) {
|
|
||||||
// Small delay to ensure smooth transition
|
|
||||||
setTimeout(() => {
|
|
||||||
setActiveIndex(clamped);
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScroll = (event: any) => {
|
|
||||||
const offsetX = event?.nativeEvent?.contentOffset?.x ?? 0;
|
|
||||||
const interval = CARD_WIDTH + 16;
|
|
||||||
const idx = Math.round(offsetX / interval);
|
|
||||||
const clamped = Math.max(0, Math.min(idx, data.length - 1));
|
|
||||||
if (clamped !== activeIndex) {
|
|
||||||
setActiveIndex(clamped);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Memoized background component with improved timing
|
// Memoized background component with improved timing
|
||||||
const BackgroundImage = React.memo(({
|
const BackgroundImage = React.memo(({
|
||||||
item,
|
item,
|
||||||
|
|
@ -146,6 +159,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!hasData) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View entering={FadeIn.duration(350).easing(Easing.out(Easing.cubic))}>
|
<Animated.View entering={FadeIn.duration(350).easing(Easing.out(Easing.cubic))}>
|
||||||
<View style={styles.container as ViewStyle}>
|
<View style={styles.container as ViewStyle}>
|
||||||
|
|
@ -186,78 +201,29 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
/>
|
/>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data}
|
data={data}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={keyExtractor}
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
snapToInterval={CARD_WIDTH + 16}
|
snapToInterval={CARD_WIDTH + 16}
|
||||||
decelerationRate="fast"
|
decelerationRate="fast"
|
||||||
contentContainerStyle={{ paddingHorizontal: (width - CARD_WIDTH) / 2 }}
|
contentContainerStyle={contentPadding}
|
||||||
onScroll={handleScroll}
|
|
||||||
onMomentumScrollEnd={handleMomentumEnd}
|
onMomentumScrollEnd={handleMomentumEnd}
|
||||||
|
initialNumToRender={3}
|
||||||
|
windowSize={3}
|
||||||
|
maxToRenderPerBatch={3}
|
||||||
|
updateCellsBatchingPeriod={50}
|
||||||
|
removeClippedSubviews
|
||||||
|
getItemLayout={getItemLayout}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={{ width: CARD_WIDTH + 16 }}>
|
<View style={{ width: CARD_WIDTH + 16 }}>
|
||||||
<TouchableOpacity
|
<CarouselCard
|
||||||
activeOpacity={0.9}
|
item={item}
|
||||||
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
|
colors={currentTheme.colors}
|
||||||
>
|
logoFailed={failedLogoIds.has(item.id)}
|
||||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }] as StyleProp<ViewStyle>}>
|
onLogoError={() => setFailedLogoIds((prev) => new Set(prev).add(item.id))}
|
||||||
<View style={styles.bannerContainer as ViewStyle}>
|
onPressInfo={() => handleNavigateToMetadata(item.id, item.type)}
|
||||||
<ExpoImage
|
onPressPlay={() => handleNavigateToStreams(item.id, item.type)}
|
||||||
source={{ uri: item.banner || item.poster }}
|
/>
|
||||||
style={styles.banner as ImageStyle}
|
|
||||||
contentFit="cover"
|
|
||||||
transition={300}
|
|
||||||
cachePolicy="memory-disk"
|
|
||||||
/>
|
|
||||||
<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 && !failedLogoIds.has(item.id) ? (
|
|
||||||
<ExpoImage
|
|
||||||
source={{ uri: item.logo }}
|
|
||||||
style={styles.logo as ImageStyle}
|
|
||||||
contentFit="contain"
|
|
||||||
transition={250}
|
|
||||||
cachePolicy="memory-disk"
|
|
||||||
onError={() => {
|
|
||||||
setFailedLogoIds((prev) => new Set(prev).add(item.id));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={[styles.title as TextStyle, { color: currentTheme.colors.highEmphasis, textAlign: 'center' }]} numberOfLines={1}>
|
|
||||||
{item.name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{item.genres && (
|
|
||||||
<Text style={[styles.genres as TextStyle, { color: currentTheme.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: currentTheme.colors.white }]}
|
|
||||||
onPress={() => navigation.navigate('Streams', { id: item.id, type: item.type })}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="play-arrow" size={22} color={currentTheme.colors.black} />
|
|
||||||
<Text style={[styles.playText as TextStyle, { color: currentTheme.colors.black }]}>Play</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.secondaryButton as ViewStyle, { borderColor: 'rgba(255,255,255,0.25)' }]}
|
|
||||||
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="info-outline" size={18} color={currentTheme.colors.white} />
|
|
||||||
<Text style={[styles.secondaryText as TextStyle, { color: currentTheme.colors.white }]}>Info</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -266,6 +232,80 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 }] as StyleProp<ViewStyle>}>
|
||||||
|
<View style={styles.bannerContainer as ViewStyle}>
|
||||||
|
<ExpoImage
|
||||||
|
source={{ uri: item.banner || item.poster }}
|
||||||
|
style={styles.banner as ImageStyle}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={300}
|
||||||
|
cachePolicy="memory-disk"
|
||||||
|
/>
|
||||||
|
<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 ? (
|
||||||
|
<ExpoImage
|
||||||
|
source={{ uri: item.logo }}
|
||||||
|
style={styles.logo as ImageStyle}
|
||||||
|
contentFit="contain"
|
||||||
|
transition={250}
|
||||||
|
cachePolicy="memory-disk"
|
||||||
|
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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue