mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +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 { View, Text, StyleSheet, Dimensions, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, FlatList, StyleProp, Platform, Image } from 'react-native';
|
||||
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 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';
|
||||
|
|
@ -47,6 +47,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
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.
|
||||
|
||||
|
|
@ -56,10 +57,32 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
const scrollX = useSharedValue(0);
|
||||
const interval = CARD_WIDTH + 16;
|
||||
|
||||
// Reset scroll position when component mounts/remounts
|
||||
// Comprehensive reset when component mounts/remounts to prevent glitching
|
||||
useEffect(() => {
|
||||
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({
|
||||
onScroll: (event) => {
|
||||
|
|
@ -96,17 +119,6 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
|
||||
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]);
|
||||
|
|
@ -133,16 +145,13 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
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)}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={CARD_WIDTH + 16}
|
||||
decelerationRate="fast"
|
||||
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={[
|
||||
styles.card,
|
||||
{
|
||||
|
|
@ -169,8 +178,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
|
@ -296,9 +305,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
<Animated.FlatList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
<Animated.ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={CARD_WIDTH + 16}
|
||||
|
|
@ -307,33 +315,24 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={8}
|
||||
disableIntervalMomentum
|
||||
initialNumToRender={3}
|
||||
windowSize={5}
|
||||
maxToRenderPerBatch={2}
|
||||
updateCellsBatchingPeriod={50}
|
||||
removeClippedSubviews={false}
|
||||
getItemLayout={getItemLayout}
|
||||
pagingEnabled={false}
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
renderItem={({ item, index }) => (
|
||||
<Animated.View
|
||||
style={{ width: CARD_WIDTH + 16 }}
|
||||
entering={FadeIn.duration(400).delay(index * 100).easing(Easing.out(Easing.cubic))}
|
||||
>
|
||||
<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)}
|
||||
scrollX={scrollX}
|
||||
index={index}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{data.map((item, index) => (
|
||||
<CarouselCard
|
||||
key={item.id}
|
||||
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)}
|
||||
scrollX={scrollX}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</Animated.ScrollView>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
|
@ -356,11 +355,18 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
|
||||
const bannerOpacity = 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(() => {
|
||||
bannerOpacity.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]);
|
||||
|
||||
const inputRange = [
|
||||
|
|
@ -376,6 +382,38 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
||||
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
|
||||
const cardAnimatedStyle = useAnimatedStyle(() => {
|
||||
|
|
@ -414,13 +452,20 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
const infoParallaxStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
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
|
||||
const parallaxOffset = -distance * 0.02;
|
||||
const parallaxOffset = -(translateX - cardOffset) * 0.02;
|
||||
|
||||
return {
|
||||
transform: [{ translateY: parallaxOffset }],
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -443,75 +488,64 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
}, [logoLoaded]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={onPressInfo}
|
||||
<Animated.View
|
||||
style={{ width: CARD_WIDTH + 16 }}
|
||||
entering={FadeIn.duration(400).delay(index * 100).easing(Easing.out(Easing.cubic))}
|
||||
>
|
||||
<Animated.View style={[
|
||||
styles.card,
|
||||
cardAnimatedStyle,
|
||||
{
|
||||
backgroundColor: colors.elevation1,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.18)',
|
||||
}
|
||||
] as StyleProp<ViewStyle>}>
|
||||
<View style={styles.bannerContainer as ViewStyle}>
|
||||
{!bannerLoaded && (
|
||||
<View style={styles.skeletonBannerFull as ViewStyle} />
|
||||
)}
|
||||
<Animated.View style={[bannerAnimatedStyle, bannerParallaxStyle, { flex: 1 }]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.banner || item.poster,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
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}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={onPressInfo}
|
||||
style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}
|
||||
>
|
||||
<Animated.View style={[
|
||||
styles.card,
|
||||
cardAnimatedStyle,
|
||||
{
|
||||
backgroundColor: colors.elevation1,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.18)',
|
||||
}
|
||||
] as StyleProp<ViewStyle>}>
|
||||
<View style={styles.bannerContainer as ViewStyle}>
|
||||
{!bannerLoaded && (
|
||||
<View style={styles.skeletonBannerFull as ViewStyle} />
|
||||
)}
|
||||
<Animated.View style={[bannerAnimatedStyle, bannerParallaxStyle, { flex: 1 }]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.logo,
|
||||
priority: FastImage.priority.high,
|
||||
source={{
|
||||
uri: item.banner || item.poster,
|
||||
priority: FastImage.priority.normal,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={styles.logo as any}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
onLoad={() => setLogoLoaded(true)}
|
||||
onError={onLogoError}
|
||||
style={styles.banner as any}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
onLoad={() => setBannerLoaded(true)}
|
||||
/>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View entering={FadeIn.duration(300)}>
|
||||
<Text style={[styles.title as TextStyle, { color: colors.highEmphasis, textAlign: 'center' }]} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
{item.genres && (
|
||||
<Animated.Text
|
||||
<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>
|
||||
{/* Static genres positioned absolutely over the card */}
|
||||
{item.genres && (
|
||||
<View style={styles.genresOverlay as ViewStyle} pointerEvents="none">
|
||||
<Animated.Text
|
||||
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}
|
||||
>
|
||||
{item.genres.slice(0, 3).join(' • ')}
|
||||
</Animated.Text>
|
||||
)}
|
||||
<Animated.View
|
||||
</View>
|
||||
)}
|
||||
{/* Static action buttons positioned absolutely over the card */}
|
||||
<View style={styles.actionsOverlay as ViewStyle} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(500).delay(200)}
|
||||
style={styles.actions as ViewStyle}
|
||||
style={[styles.actions as ViewStyle, actionsAnimatedStyle]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
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>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* Static logo positioned absolutely over the card */}
|
||||
{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,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ interface TrailerVideo {
|
|||
type: string;
|
||||
official: boolean;
|
||||
published_at: string;
|
||||
seasonNumber: number | null;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
interface TrailersSectionProps {
|
||||
|
|
@ -153,28 +155,93 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
return;
|
||||
}
|
||||
|
||||
const videosEndpoint = type === 'movie'
|
||||
? `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`;
|
||||
let allVideos: any[] = [];
|
||||
|
||||
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);
|
||||
if (!response.ok) {
|
||||
// 404 is normal - means no videos exist for this content
|
||||
if (response.status === 404) {
|
||||
logger.info('TrailersSection', `No videos found for TMDB ID ${tmdbId} (404 response)`);
|
||||
setTrailers({}); // Empty trailers - section won't render
|
||||
return;
|
||||
logger.info('TrailersSection', `Fetching movie videos from: ${videosEndpoint}`);
|
||||
|
||||
const response = await fetch(videosEndpoint);
|
||||
if (!response.ok) {
|
||||
// 404 is normal - means no videos exist for this content
|
||||
if (response.status === 404) {
|
||||
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();
|
||||
logger.info('TrailersSection', `Received ${data.results?.length || 0} videos for TMDB ID ${tmdbId}`);
|
||||
|
||||
const categorized = categorizeTrailers(data.results || []);
|
||||
const categorized = categorizeTrailers(allVideos);
|
||||
const totalVideos = Object.values(categorized).reduce((sum, videos) => sum + videos.length, 0);
|
||||
|
||||
if (totalVideos === 0) {
|
||||
|
|
@ -219,10 +286,21 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
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 => {
|
||||
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;
|
||||
|
||||
|
|
@ -506,7 +584,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
style={[styles.trailerTitle, { color: currentTheme.colors.highEmphasis }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{trailer.name}
|
||||
{trailer.displayName || trailer.name}
|
||||
</Text>
|
||||
<Text style={[styles.trailerMeta, { color: currentTheme.colors.textMuted }]}>
|
||||
{new Date(trailer.published_at).getFullYear()}
|
||||
|
|
|
|||
Loading…
Reference in a new issue