From 1535ef9aacf8e6cb9bf23d600cffd63bb2d4aff9 Mon Sep 17 00:00:00 2001 From: tapframe Date: Fri, 17 Oct 2025 23:19:55 +0530 Subject: [PATCH] trailer improvements --- src/components/home/HeroCarousel.tsx | 328 +++++++++++++------- src/components/metadata/TrailersSection.tsx | 118 +++++-- 2 files changed, 313 insertions(+), 133 deletions(-) diff --git a/src/components/home/HeroCarousel.tsx b/src/components/home/HeroCarousel.tsx index bf6a311c..58b13981 100644 --- a/src/components/home/HeroCarousel.tsx +++ b/src/components/home/HeroCarousel.tsx @@ -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 = ({ items, loading = false }) = const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]); const [activeIndex, setActiveIndex] = useState(0); const [failedLogoIds, setFailedLogoIds] = useState>(new Set()); + const scrollViewRef = useRef(null); // Note: do not early-return before hooks. Loading UI is returned later. @@ -56,10 +57,32 @@ const HeroCarousel: React.FC = ({ 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 = ({ 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 = ({ items, loading = false }) = return ( }> - String(i)} + ( - + > + {[1, 2, 3].map((_, index) => ( + = ({ items, loading = false }) = - )} - /> + ))} + ); @@ -296,9 +305,8 @@ const HeroCarousel: React.FC = ({ items, loading = false }) = pointerEvents="none" /> )} - = ({ 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 }) => ( - - setFailedLogoIds((prev) => new Set(prev).add(item.id))} - onPressInfo={() => handleNavigateToMetadata(item.id, item.type)} - onPressPlay={() => handleNavigateToStreams(item.id, item.type)} - scrollX={scrollX} - index={index} - /> - - )} - /> + > + {data.map((item, index) => ( + setFailedLogoIds((prev) => new Set(prev).add(item.id))} + onPressInfo={() => handleNavigateToMetadata(item.id, item.type)} + onPressPlay={() => handleNavigateToStreams(item.id, item.type)} + scrollX={scrollX} + index={index} + /> + ))} + ); @@ -356,11 +355,18 @@ const CarouselCard: React.FC = 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 = 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 = 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 = memo(({ item, colors, logoFail }, [logoLoaded]); return ( - - }> - - {!bannerLoaded && ( - - )} - - setBannerLoaded(true)} - /> - - - - - {item.logo && !logoFailed ? ( - + + }> + + {!bannerLoaded && ( + + )} + setLogoLoaded(true)} - onError={onLogoError} + style={styles.banner as any} + resizeMode={FastImage.resizeMode.cover} + onLoad={() => setBannerLoaded(true)} /> - ) : ( - - - {item.name} - - - )} - {item.genres && ( - + + + {/* Static genres positioned absolutely over the card */} + {item.genres && ( + + {item.genres.slice(0, 3).join(' • ')} - )} - + )} + {/* Static action buttons positioned absolutely over the card */} + + = memo(({ item, colors, logoFail Info - - - + + {/* Static logo positioned absolutely over the card */} + {item.logo && !logoFailed && ( + + + setLogoLoaded(true)} + onError={onLogoError} + /> + + + )} + {/* Static title when no logo */} + {!item.logo || logoFailed ? ( + + + + {item.name} + + + + ) : null} + + ); }); @@ -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); diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx index db2b700a..fffa5906 100644 --- a/src/components/metadata/TrailersSection.tsx +++ b/src/components/metadata/TrailersSection.tsx @@ -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 = 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 = 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 = memo(({ style={[styles.trailerTitle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={2} > - {trailer.name} + {trailer.displayName || trailer.name} {new Date(trailer.published_at).getFullYear()}