mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-03 08:49:07 +00:00
trailer improvements
This commit is contained in:
parent
bb6f1f32a0
commit
1535ef9aac
2 changed files with 313 additions and 133 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
|
import React, { useMemo, useState, useEffect, useCallback, memo, useRef } from 'react';
|
||||||
import { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, FlatList, StyleProp, Platform, Image } from 'react-native';
|
import { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, ScrollView, StyleProp, Platform, Image } from 'react-native';
|
||||||
import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle, useAnimatedScrollHandler, useAnimatedReaction, runOnJS, SharedValue } 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 { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
|
|
@ -47,6 +47,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]);
|
const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]);
|
||||||
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());
|
||||||
|
const scrollViewRef = useRef<any>(null);
|
||||||
|
|
||||||
// Note: do not early-return before hooks. Loading UI is returned later.
|
// Note: do not early-return before hooks. Loading UI is returned later.
|
||||||
|
|
||||||
|
|
@ -56,11 +57,33 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
const scrollX = useSharedValue(0);
|
const scrollX = useSharedValue(0);
|
||||||
const interval = CARD_WIDTH + 16;
|
const interval = CARD_WIDTH + 16;
|
||||||
|
|
||||||
// Reset scroll position when component mounts/remounts
|
// Comprehensive reset when component mounts/remounts to prevent glitching
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollX.value = 0;
|
scrollX.value = 0;
|
||||||
|
setActiveIndex(0);
|
||||||
|
|
||||||
|
// Scroll to position 0 after a brief delay to ensure ScrollView is ready
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
scrollViewRef.current?.scrollTo({ x: 0, y: 0, animated: false });
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Reset scroll when data becomes available
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length > 0) {
|
||||||
|
scrollX.value = 0;
|
||||||
|
setActiveIndex(0);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
scrollViewRef.current?.scrollTo({ x: 0, y: 0, animated: false });
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [data.length]);
|
||||||
|
|
||||||
const scrollHandler = useAnimatedScrollHandler({
|
const scrollHandler = useAnimatedScrollHandler({
|
||||||
onScroll: (event) => {
|
onScroll: (event) => {
|
||||||
scrollX.value = event.contentOffset.x;
|
scrollX.value = event.contentOffset.x;
|
||||||
|
|
@ -96,17 +119,6 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
|
|
||||||
const contentPadding = useMemo(() => ({ paddingHorizontal: (width - CARD_WIDTH) / 2 }), []);
|
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) => {
|
const handleNavigateToMetadata = useCallback((id: string, type: any) => {
|
||||||
navigation.navigate('Metadata', { id, type });
|
navigation.navigate('Metadata', { id, type });
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
@ -133,16 +145,13 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { paddingVertical: 12 }] as StyleProp<ViewStyle>}>
|
<View style={[styles.container, { paddingVertical: 12 }] as StyleProp<ViewStyle>}>
|
||||||
<View style={{ height: CARD_HEIGHT }}>
|
<View style={{ height: CARD_HEIGHT }}>
|
||||||
<FlatList
|
<ScrollView
|
||||||
data={[1, 2, 3] as any}
|
|
||||||
keyExtractor={(i) => String(i)}
|
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
snapToInterval={CARD_WIDTH + 16}
|
|
||||||
decelerationRate="fast"
|
|
||||||
contentContainerStyle={{ paddingHorizontal: (width - CARD_WIDTH) / 2 }}
|
contentContainerStyle={{ paddingHorizontal: (width - CARD_WIDTH) / 2 }}
|
||||||
renderItem={() => (
|
>
|
||||||
<View style={{ width: CARD_WIDTH + 16 }}>
|
{[1, 2, 3].map((_, index) => (
|
||||||
|
<View key={index} style={{ width: CARD_WIDTH + 16 }}>
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.card,
|
styles.card,
|
||||||
{
|
{
|
||||||
|
|
@ -169,8 +178,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
))}
|
||||||
/>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
@ -296,9 +305,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Animated.FlatList
|
<Animated.ScrollView
|
||||||
data={data}
|
ref={scrollViewRef}
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
snapToInterval={CARD_WIDTH + 16}
|
snapToInterval={CARD_WIDTH + 16}
|
||||||
|
|
@ -307,33 +315,24 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
||||||
onScroll={scrollHandler}
|
onScroll={scrollHandler}
|
||||||
scrollEventThrottle={8}
|
scrollEventThrottle={8}
|
||||||
disableIntervalMomentum
|
disableIntervalMomentum
|
||||||
initialNumToRender={3}
|
|
||||||
windowSize={5}
|
|
||||||
maxToRenderPerBatch={2}
|
|
||||||
updateCellsBatchingPeriod={50}
|
|
||||||
removeClippedSubviews={false}
|
|
||||||
getItemLayout={getItemLayout}
|
|
||||||
pagingEnabled={false}
|
pagingEnabled={false}
|
||||||
bounces={false}
|
bounces={false}
|
||||||
overScrollMode="never"
|
overScrollMode="never"
|
||||||
renderItem={({ item, index }) => (
|
>
|
||||||
<Animated.View
|
{data.map((item, index) => (
|
||||||
style={{ width: CARD_WIDTH + 16 }}
|
<CarouselCard
|
||||||
entering={FadeIn.duration(400).delay(index * 100).easing(Easing.out(Easing.cubic))}
|
key={item.id}
|
||||||
>
|
item={item}
|
||||||
<CarouselCard
|
colors={currentTheme.colors}
|
||||||
item={item}
|
logoFailed={failedLogoIds.has(item.id)}
|
||||||
colors={currentTheme.colors}
|
onLogoError={() => setFailedLogoIds((prev) => new Set(prev).add(item.id))}
|
||||||
logoFailed={failedLogoIds.has(item.id)}
|
onPressInfo={() => handleNavigateToMetadata(item.id, item.type)}
|
||||||
onLogoError={() => setFailedLogoIds((prev) => new Set(prev).add(item.id))}
|
onPressPlay={() => handleNavigateToStreams(item.id, item.type)}
|
||||||
onPressInfo={() => handleNavigateToMetadata(item.id, item.type)}
|
scrollX={scrollX}
|
||||||
onPressPlay={() => handleNavigateToStreams(item.id, item.type)}
|
index={index}
|
||||||
scrollX={scrollX}
|
/>
|
||||||
index={index}
|
))}
|
||||||
/>
|
</Animated.ScrollView>
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|
@ -356,11 +355,18 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
||||||
|
|
||||||
const bannerOpacity = useSharedValue(0);
|
const bannerOpacity = useSharedValue(0);
|
||||||
const logoOpacity = useSharedValue(0);
|
const logoOpacity = useSharedValue(0);
|
||||||
|
const genresOpacity = useSharedValue(0);
|
||||||
|
const actionsOpacity = useSharedValue(0);
|
||||||
|
|
||||||
// Reset animations when component mounts/remounts
|
// Reset animations when component mounts/remounts to prevent glitching
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bannerOpacity.value = 0;
|
bannerOpacity.value = 0;
|
||||||
logoOpacity.value = 0;
|
logoOpacity.value = 0;
|
||||||
|
genresOpacity.value = 0;
|
||||||
|
actionsOpacity.value = 0;
|
||||||
|
// Force re-render states to ensure clean state
|
||||||
|
setBannerLoaded(false);
|
||||||
|
setLogoLoaded(false);
|
||||||
}, [item.id]);
|
}, [item.id]);
|
||||||
|
|
||||||
const inputRange = [
|
const inputRange = [
|
||||||
|
|
@ -377,6 +383,38 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
||||||
opacity: logoOpacity.value,
|
opacity: logoOpacity.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const genresAnimatedStyle = useAnimatedStyle(() => {
|
||||||
|
const translateX = scrollX.value;
|
||||||
|
const cardOffset = index * (CARD_WIDTH + 16);
|
||||||
|
const distance = Math.abs(translateX - cardOffset);
|
||||||
|
const maxDistance = (CARD_WIDTH + 16) * 0.5; // Smaller threshold for smoother transition
|
||||||
|
|
||||||
|
// Hide genres when scrolling (not centered)
|
||||||
|
const progress = Math.min(distance / maxDistance, 1);
|
||||||
|
const opacity = 1 - progress; // Linear fade out
|
||||||
|
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
|
||||||
|
return {
|
||||||
|
opacity: clampedOpacity,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionsAnimatedStyle = useAnimatedStyle(() => {
|
||||||
|
const translateX = scrollX.value;
|
||||||
|
const cardOffset = index * (CARD_WIDTH + 16);
|
||||||
|
const distance = Math.abs(translateX - cardOffset);
|
||||||
|
const maxDistance = (CARD_WIDTH + 16) * 0.5; // Smaller threshold for smoother transition
|
||||||
|
|
||||||
|
// Hide actions when scrolling (not centered)
|
||||||
|
const progress = Math.min(distance / maxDistance, 1);
|
||||||
|
const opacity = 1 - progress; // Linear fade out
|
||||||
|
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
|
||||||
|
return {
|
||||||
|
opacity: clampedOpacity,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Scroll-based animations
|
// Scroll-based animations
|
||||||
const cardAnimatedStyle = useAnimatedStyle(() => {
|
const cardAnimatedStyle = useAnimatedStyle(() => {
|
||||||
const translateX = scrollX.value;
|
const translateX = scrollX.value;
|
||||||
|
|
@ -414,13 +452,20 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
||||||
const infoParallaxStyle = useAnimatedStyle(() => {
|
const infoParallaxStyle = useAnimatedStyle(() => {
|
||||||
const translateX = scrollX.value;
|
const translateX = scrollX.value;
|
||||||
const cardOffset = index * (CARD_WIDTH + 16);
|
const cardOffset = index * (CARD_WIDTH + 16);
|
||||||
const distance = translateX - cardOffset;
|
const distance = Math.abs(translateX - cardOffset);
|
||||||
|
const maxDistance = CARD_WIDTH + 16;
|
||||||
|
|
||||||
|
// Hide info section when scrolling (not centered)
|
||||||
|
const progress = distance / maxDistance;
|
||||||
|
const opacity = 1 - progress * 2; // Fade out faster when scrolling
|
||||||
|
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
|
||||||
// Minimal parallax for info section to prevent displacement
|
// Minimal parallax for info section to prevent displacement
|
||||||
const parallaxOffset = -distance * 0.02;
|
const parallaxOffset = -(translateX - cardOffset) * 0.02;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transform: [{ translateY: parallaxOffset }],
|
transform: [{ translateY: parallaxOffset }],
|
||||||
|
opacity: clampedOpacity,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -443,75 +488,64 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
||||||
}, [logoLoaded]);
|
}, [logoLoaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<Animated.View
|
||||||
activeOpacity={0.9}
|
style={{ width: CARD_WIDTH + 16 }}
|
||||||
onPress={onPressInfo}
|
entering={FadeIn.duration(400).delay(index * 100).easing(Easing.out(Easing.cubic))}
|
||||||
>
|
>
|
||||||
<Animated.View style={[
|
<TouchableOpacity
|
||||||
styles.card,
|
activeOpacity={0.9}
|
||||||
cardAnimatedStyle,
|
onPress={onPressInfo}
|
||||||
{
|
style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}
|
||||||
backgroundColor: colors.elevation1,
|
>
|
||||||
borderWidth: 1,
|
<Animated.View style={[
|
||||||
borderColor: 'rgba(255,255,255,0.18)',
|
styles.card,
|
||||||
}
|
cardAnimatedStyle,
|
||||||
] as StyleProp<ViewStyle>}>
|
{
|
||||||
<View style={styles.bannerContainer as ViewStyle}>
|
backgroundColor: colors.elevation1,
|
||||||
{!bannerLoaded && (
|
borderWidth: 1,
|
||||||
<View style={styles.skeletonBannerFull as ViewStyle} />
|
borderColor: 'rgba(255,255,255,0.18)',
|
||||||
)}
|
}
|
||||||
<Animated.View style={[bannerAnimatedStyle, bannerParallaxStyle, { flex: 1 }]}>
|
] as StyleProp<ViewStyle>}>
|
||||||
<FastImage
|
<View style={styles.bannerContainer as ViewStyle}>
|
||||||
source={{
|
{!bannerLoaded && (
|
||||||
uri: item.banner || item.poster,
|
<View style={styles.skeletonBannerFull as ViewStyle} />
|
||||||
priority: FastImage.priority.normal,
|
)}
|
||||||
cache: FastImage.cacheControl.immutable
|
<Animated.View style={[bannerAnimatedStyle, bannerParallaxStyle, { flex: 1 }]}>
|
||||||
}}
|
|
||||||
style={styles.banner as any}
|
|
||||||
resizeMode={FastImage.resizeMode.cover}
|
|
||||||
onLoad={() => setBannerLoaded(true)}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
<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>
|
|
||||||
<Animated.View style={[styles.info as ViewStyle, infoParallaxStyle]}>
|
|
||||||
{item.logo && !logoFailed ? (
|
|
||||||
<Animated.View style={logoAnimatedStyle}>
|
|
||||||
<FastImage
|
<FastImage
|
||||||
source={{
|
source={{
|
||||||
uri: item.logo,
|
uri: item.banner || item.poster,
|
||||||
priority: FastImage.priority.high,
|
priority: FastImage.priority.normal,
|
||||||
cache: FastImage.cacheControl.immutable
|
cache: FastImage.cacheControl.immutable
|
||||||
}}
|
}}
|
||||||
style={styles.logo as any}
|
style={styles.banner as any}
|
||||||
resizeMode={FastImage.resizeMode.contain}
|
resizeMode={FastImage.resizeMode.cover}
|
||||||
onLoad={() => setLogoLoaded(true)}
|
onLoad={() => setBannerLoaded(true)}
|
||||||
onError={onLogoError}
|
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
) : (
|
<LinearGradient
|
||||||
<Animated.View entering={FadeIn.duration(300)}>
|
colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.6)"]}
|
||||||
<Text style={[styles.title as TextStyle, { color: colors.highEmphasis, textAlign: 'center' }]} numberOfLines={1}>
|
locations={[0.4, 0.7, 1]}
|
||||||
{item.name}
|
style={styles.bannerGradient as ViewStyle}
|
||||||
</Text>
|
/>
|
||||||
</Animated.View>
|
</View>
|
||||||
)}
|
</Animated.View>
|
||||||
{item.genres && (
|
{/* Static genres positioned absolutely over the card */}
|
||||||
|
{item.genres && (
|
||||||
|
<View style={styles.genresOverlay as ViewStyle} pointerEvents="none">
|
||||||
<Animated.Text
|
<Animated.Text
|
||||||
entering={FadeIn.duration(400).delay(100)}
|
entering={FadeIn.duration(400).delay(100)}
|
||||||
style={[styles.genres as TextStyle, { color: colors.mediumEmphasis, textAlign: 'center' }]}
|
style={[styles.genres as TextStyle, { color: colors.mediumEmphasis, textAlign: 'center' }, genresAnimatedStyle]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{item.genres.slice(0, 3).join(' • ')}
|
{item.genres.slice(0, 3).join(' • ')}
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
)}
|
</View>
|
||||||
|
)}
|
||||||
|
{/* Static action buttons positioned absolutely over the card */}
|
||||||
|
<View style={styles.actionsOverlay as ViewStyle} pointerEvents="box-none">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeIn.duration(500).delay(200)}
|
entering={FadeIn.duration(500).delay(200)}
|
||||||
style={styles.actions as ViewStyle}
|
style={[styles.actions as ViewStyle, actionsAnimatedStyle]}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.playButton as ViewStyle, { backgroundColor: colors.white }]}
|
style={[styles.playButton as ViewStyle, { backgroundColor: colors.white }]}
|
||||||
|
|
@ -530,9 +564,37 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
||||||
<Text style={[styles.secondaryText as TextStyle, { color: colors.white }]}>Info</Text>
|
<Text style={[styles.secondaryText as TextStyle, { color: colors.white }]}>Info</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</View>
|
||||||
</Animated.View>
|
{/* Static logo positioned absolutely over the card */}
|
||||||
</TouchableOpacity>
|
{item.logo && !logoFailed && (
|
||||||
|
<View style={styles.logoOverlay as ViewStyle} pointerEvents="none">
|
||||||
|
<Animated.View style={logoAnimatedStyle}>
|
||||||
|
<FastImage
|
||||||
|
source={{
|
||||||
|
uri: item.logo,
|
||||||
|
priority: FastImage.priority.high,
|
||||||
|
cache: FastImage.cacheControl.immutable
|
||||||
|
}}
|
||||||
|
style={styles.logo as any}
|
||||||
|
resizeMode={FastImage.resizeMode.contain}
|
||||||
|
onLoad={() => setLogoLoaded(true)}
|
||||||
|
onError={onLogoError}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* Static title when no logo */}
|
||||||
|
{!item.logo || logoFailed ? (
|
||||||
|
<View style={styles.titleOverlay as ViewStyle} pointerEvents="none">
|
||||||
|
<Animated.View entering={FadeIn.duration(300)}>
|
||||||
|
<Text style={[styles.title as TextStyle, { color: colors.highEmphasis, textAlign: 'center' }]} numberOfLines={1}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -694,6 +756,46 @@ const styles = StyleSheet.create({
|
||||||
marginLeft: 6,
|
marginLeft: 6,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
|
logoOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingBottom: 80, // Position above genres and actions
|
||||||
|
},
|
||||||
|
titleOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingBottom: 90, // Position above genres and actions
|
||||||
|
},
|
||||||
|
genresOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingBottom: 65, // Position above actions
|
||||||
|
},
|
||||||
|
actionsOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingBottom: 12, // Position at bottom
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default React.memo(HeroCarousel);
|
export default React.memo(HeroCarousel);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ interface TrailerVideo {
|
||||||
type: string;
|
type: string;
|
||||||
official: boolean;
|
official: boolean;
|
||||||
published_at: string;
|
published_at: string;
|
||||||
|
seasonNumber: number | null;
|
||||||
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrailersSectionProps {
|
interface TrailersSectionProps {
|
||||||
|
|
@ -153,28 +155,93 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videosEndpoint = type === 'movie'
|
let allVideos: any[] = [];
|
||||||
? `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`
|
|
||||||
: `https://api.themoviedb.org/3/tv/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`;
|
|
||||||
|
|
||||||
logger.info('TrailersSection', `Fetching videos from: ${videosEndpoint}`);
|
if (type === 'movie') {
|
||||||
|
// For movies, just fetch the main videos endpoint
|
||||||
|
const videosEndpoint = `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`;
|
||||||
|
|
||||||
const response = await fetch(videosEndpoint);
|
logger.info('TrailersSection', `Fetching movie videos from: ${videosEndpoint}`);
|
||||||
if (!response.ok) {
|
|
||||||
// 404 is normal - means no videos exist for this content
|
const response = await fetch(videosEndpoint);
|
||||||
if (response.status === 404) {
|
if (!response.ok) {
|
||||||
logger.info('TrailersSection', `No videos found for TMDB ID ${tmdbId} (404 response)`);
|
// 404 is normal - means no videos exist for this content
|
||||||
setTrailers({}); // Empty trailers - section won't render
|
if (response.status === 404) {
|
||||||
return;
|
logger.info('TrailersSection', `No videos found for movie TMDB ID ${tmdbId} (404 response)`);
|
||||||
|
setTrailers({}); // Empty trailers - section won't render
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('TrailersSection', `Videos endpoint failed: ${response.status} ${response.statusText}`);
|
||||||
|
throw new Error(`Failed to fetch trailers: ${response.status}`);
|
||||||
}
|
}
|
||||||
logger.error('TrailersSection', `Videos endpoint failed: ${response.status} ${response.statusText}`);
|
|
||||||
throw new Error(`Failed to fetch trailers: ${response.status}`);
|
const data = await response.json();
|
||||||
|
allVideos = data.results || [];
|
||||||
|
logger.info('TrailersSection', `Received ${allVideos.length} videos for movie TMDB ID ${tmdbId}`);
|
||||||
|
} else {
|
||||||
|
// For TV shows, fetch both main TV videos and season-specific videos
|
||||||
|
logger.info('TrailersSection', `Fetching TV show videos and season trailers for TMDB ID ${tmdbId}`);
|
||||||
|
|
||||||
|
// Get TV show details to know how many seasons there are
|
||||||
|
const tvDetailsResponse = await fetch(basicEndpoint);
|
||||||
|
const tvDetails = await tvDetailsResponse.json();
|
||||||
|
const numberOfSeasons = tvDetails.number_of_seasons || 0;
|
||||||
|
|
||||||
|
logger.info('TrailersSection', `TV show has ${numberOfSeasons} seasons`);
|
||||||
|
|
||||||
|
// Fetch main TV show videos
|
||||||
|
const tvVideosEndpoint = `https://api.themoviedb.org/3/tv/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`;
|
||||||
|
const tvResponse = await fetch(tvVideosEndpoint);
|
||||||
|
|
||||||
|
if (tvResponse.ok) {
|
||||||
|
const tvData = await tvResponse.json();
|
||||||
|
// Add season info to main TV videos
|
||||||
|
const mainVideos = (tvData.results || []).map((video: any) => ({
|
||||||
|
...video,
|
||||||
|
seasonNumber: null as number | null, // null indicates main TV show videos
|
||||||
|
displayName: video.name
|
||||||
|
}));
|
||||||
|
allVideos.push(...mainVideos);
|
||||||
|
logger.info('TrailersSection', `Received ${mainVideos.length} main TV videos`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch videos from each season (skip season 0 which is specials)
|
||||||
|
const seasonPromises = [];
|
||||||
|
for (let seasonNum = 1; seasonNum <= numberOfSeasons; seasonNum++) {
|
||||||
|
seasonPromises.push(
|
||||||
|
fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNum}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => ({
|
||||||
|
seasonNumber: seasonNum,
|
||||||
|
videos: data.results || []
|
||||||
|
}))
|
||||||
|
.catch(err => {
|
||||||
|
logger.warn('TrailersSection', `Failed to fetch season ${seasonNum} videos:`, err);
|
||||||
|
return { seasonNumber: seasonNum, videos: [] };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonResults = await Promise.all(seasonPromises);
|
||||||
|
|
||||||
|
// Add season videos to the collection
|
||||||
|
seasonResults.forEach(result => {
|
||||||
|
if (result.videos.length > 0) {
|
||||||
|
const seasonVideos = result.videos.map((video: any) => ({
|
||||||
|
...video,
|
||||||
|
seasonNumber: result.seasonNumber as number | null,
|
||||||
|
displayName: `Season ${result.seasonNumber} - ${video.name}`
|
||||||
|
}));
|
||||||
|
allVideos.push(...seasonVideos);
|
||||||
|
logger.info('TrailersSection', `Season ${result.seasonNumber}: ${result.videos.length} videos`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalSeasonVideos = seasonResults.reduce((sum, result) => sum + result.videos.length, 0);
|
||||||
|
logger.info('TrailersSection', `Total videos collected: ${allVideos.length} (main: ${allVideos.filter(v => v.seasonNumber === null).length}, seasons: ${totalSeasonVideos})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const categorized = categorizeTrailers(allVideos);
|
||||||
logger.info('TrailersSection', `Received ${data.results?.length || 0} videos for TMDB ID ${tmdbId}`);
|
|
||||||
|
|
||||||
const categorized = categorizeTrailers(data.results || []);
|
|
||||||
const totalVideos = Object.values(categorized).reduce((sum, videos) => sum + videos.length, 0);
|
const totalVideos = Object.values(categorized).reduce((sum, videos) => sum + videos.length, 0);
|
||||||
|
|
||||||
if (totalVideos === 0) {
|
if (totalVideos === 0) {
|
||||||
|
|
@ -219,10 +286,21 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
categories[category].push(video);
|
categories[category].push(video);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort within each category: official trailers first, then by published date (newest first)
|
// Sort within each category: season trailers first (newest seasons), then main series, official first, then by date
|
||||||
Object.keys(categories).forEach(category => {
|
Object.keys(categories).forEach(category => {
|
||||||
categories[category].sort((a, b) => {
|
categories[category].sort((a, b) => {
|
||||||
// Official trailers come first
|
// Season trailers come before main series trailers
|
||||||
|
if (a.seasonNumber !== null && b.seasonNumber === null) return -1;
|
||||||
|
if (a.seasonNumber === null && b.seasonNumber !== null) return 1;
|
||||||
|
|
||||||
|
// If both have season numbers, sort by season number (newest seasons first)
|
||||||
|
if (a.seasonNumber !== null && b.seasonNumber !== null) {
|
||||||
|
if (a.seasonNumber !== b.seasonNumber) {
|
||||||
|
return b.seasonNumber - a.seasonNumber; // Higher season numbers first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Official trailers come first within the same season/main series group
|
||||||
if (a.official && !b.official) return -1;
|
if (a.official && !b.official) return -1;
|
||||||
if (!a.official && b.official) return 1;
|
if (!a.official && b.official) return 1;
|
||||||
|
|
||||||
|
|
@ -506,7 +584,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
style={[styles.trailerTitle, { color: currentTheme.colors.highEmphasis }]}
|
style={[styles.trailerTitle, { color: currentTheme.colors.highEmphasis }]}
|
||||||
numberOfLines={2}
|
numberOfLines={2}
|
||||||
>
|
>
|
||||||
{trailer.name}
|
{trailer.displayName || trailer.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.trailerMeta, { color: currentTheme.colors.textMuted }]}>
|
<Text style={[styles.trailerMeta, { color: currentTheme.colors.textMuted }]}>
|
||||||
{new Date(trailer.published_at).getFullYear()}
|
{new Date(trailer.published_at).getFullYear()}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue