From face30c1638a566b4b5c2dadc719c8d6c00c0877 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 31 Aug 2025 12:53:32 +0530 Subject: [PATCH] trailer ininital addition --- src/components/metadata/HeroSection.tsx | 58 +++- src/components/video/TrailerPlayer.tsx | 367 ++++++++++++++++++++++++ src/services/trailerService.ts | 166 +++++++++++ 3 files changed, 587 insertions(+), 4 deletions(-) create mode 100644 src/components/video/TrailerPlayer.tsx create mode 100644 src/services/trailerService.ts diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index ab3f34ab..59cba797 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -29,6 +29,8 @@ import { useTheme } from '../../contexts/ThemeContext'; import { useTraktContext } from '../../contexts/TraktContext'; import { logger } from '../../utils/logger'; import { TMDBService } from '../../services/tmdbService'; +import TrailerService from '../../services/trailerService'; +import TrailerPlayer from '../video/TrailerPlayer'; const { width, height } = Dimensions.get('window'); const isTablet = width >= 768; @@ -693,6 +695,11 @@ const HeroSection: React.FC = memo(({ // Image loading state with optimized management const [imageError, setImageError] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); + const [trailerUrl, setTrailerUrl] = useState(null); + const [trailerLoading, setTrailerLoading] = useState(false); + const [trailerError, setTrailerError] = useState(false); + const [trailerMuted, setTrailerMuted] = useState(true); + const [trailerPlaying, setTrailerPlaying] = useState(false); const imageOpacity = useSharedValue(1); const imageLoadOpacity = useSharedValue(0); const shimmerOpacity = useSharedValue(0.3); @@ -722,6 +729,33 @@ const HeroSection: React.FC = memo(({ return () => timer.cancel(); }, []); + // Fetch trailer URL when component mounts + useEffect(() => { + const fetchTrailer = async () => { + if (!metadata?.name || !metadata?.year) return; + + setTrailerLoading(true); + setTrailerError(false); + + try { + const url = await TrailerService.getTrailerUrl(metadata.name, metadata.year); + if (url) { + setTrailerUrl(TrailerService.getBestFormatUrl(url)); + logger.info('HeroSection', `Trailer loaded for ${metadata.name}`); + } else { + logger.info('HeroSection', `No trailer found for ${metadata.name}`); + } + } catch (error) { + logger.error('HeroSection', 'Error fetching trailer:', error); + setTrailerError(true); + } finally { + setTrailerLoading(false); + } + }; + + fetchTrailer(); + }, [metadata?.name, metadata?.year]); + // Optimized shimmer animation for loading state useEffect(() => { if (!shouldLoadSecondaryData) return; @@ -903,7 +937,7 @@ const HeroSection: React.FC = memo(({ {/* Optimized shimmer loading effect */} - {shouldLoadSecondaryData && ((imageSource && !imageLoaded) || loadingBanner) && ( + {shouldLoadSecondaryData && (trailerLoading || ((imageSource && !imageLoaded) || loadingBanner)) && ( @@ -916,8 +950,24 @@ const HeroSection: React.FC = memo(({ )} - {/* Optimized background image with lazy loading */} - {shouldLoadSecondaryData && imageSource && !loadingBanner && ( + {/* Trailer player or background image */} + {shouldLoadSecondaryData && trailerUrl && !trailerLoading && !trailerError ? ( + { + logger.warn('HeroSection', 'Trailer playback failed, falling back to image'); + setTrailerError(true); + }} + onProgress={() => { + if (!trailerPlaying) { + setTrailerPlaying(true); + } + }} + /> + ) : shouldLoadSecondaryData && imageSource && !loadingBanner ? ( = memo(({ onError={handleImageError} onLoad={handleImageLoad} /> - )} + ) : null} diff --git a/src/components/video/TrailerPlayer.tsx b/src/components/video/TrailerPlayer.tsx new file mode 100644 index 00000000..d3a38bf5 --- /dev/null +++ b/src/components/video/TrailerPlayer.tsx @@ -0,0 +1,367 @@ +import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; +import { + View, + StyleSheet, + TouchableOpacity, + Dimensions, + Platform, + ActivityIndicator, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import Video, { VideoRef, OnLoadData, OnProgressData } from 'react-native-video'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, + withDelay, + runOnJS, +} from 'react-native-reanimated'; +import { useTheme } from '../../contexts/ThemeContext'; +import { logger } from '../../utils/logger'; + +const { width, height } = Dimensions.get('window'); +const isTablet = width >= 768; + +interface TrailerPlayerProps { + trailerUrl: string; + autoPlay?: boolean; + muted?: boolean; + onLoadStart?: () => void; + onLoad?: () => void; + onError?: (error: string) => void; + onProgress?: (data: OnProgressData) => void; + style?: any; +} + +const TrailerPlayer: React.FC = memo(({ + trailerUrl, + autoPlay = true, + muted = true, + onLoadStart, + onLoad, + onError, + onProgress, + style, +}) => { + const { currentTheme } = useTheme(); + const videoRef = useRef(null); + + const [isLoading, setIsLoading] = useState(true); + const [isPlaying, setIsPlaying] = useState(autoPlay); + const [isMuted, setIsMuted] = useState(muted); + const [hasError, setHasError] = useState(false); + const [showControls, setShowControls] = useState(false); + const [duration, setDuration] = useState(0); + const [position, setPosition] = useState(0); + + // Animated values + const controlsOpacity = useSharedValue(0); + const loadingOpacity = useSharedValue(1); + const playButtonScale = useSharedValue(1); + + // Auto-hide controls after 3 seconds + const hideControlsTimeout = useRef(null); + + const showControlsWithTimeout = useCallback(() => { + setShowControls(true); + controlsOpacity.value = withTiming(1, { duration: 200 }); + + // Clear existing timeout + if (hideControlsTimeout.current) { + clearTimeout(hideControlsTimeout.current); + } + + // Set new timeout to hide controls + hideControlsTimeout.current = setTimeout(() => { + setShowControls(false); + controlsOpacity.value = withTiming(0, { duration: 200 }); + }, 3000); + }, [controlsOpacity]); + + const handleVideoPress = useCallback(() => { + if (showControls) { + // If controls are visible, toggle play/pause + handlePlayPause(); + } else { + // If controls are hidden, show them + showControlsWithTimeout(); + } + }, [showControls, showControlsWithTimeout]); + + const handlePlayPause = useCallback(async () => { + try { + if (!videoRef.current) return; + + playButtonScale.value = withTiming(0.8, { duration: 100 }, () => { + playButtonScale.value = withTiming(1, { duration: 100 }); + }); + + setIsPlaying(!isPlaying); + + showControlsWithTimeout(); + } catch (error) { + logger.error('TrailerPlayer', 'Error toggling playback:', error); + } + }, [isPlaying, playButtonScale, showControlsWithTimeout]); + + const handleMuteToggle = useCallback(async () => { + try { + if (!videoRef.current) return; + + setIsMuted(!isMuted); + showControlsWithTimeout(); + } catch (error) { + logger.error('TrailerPlayer', 'Error toggling mute:', error); + } + }, [isMuted, showControlsWithTimeout]); + + const handleLoadStart = useCallback(() => { + setIsLoading(true); + setHasError(false); + loadingOpacity.value = 1; + onLoadStart?.(); + logger.info('TrailerPlayer', 'Video load started'); + }, [loadingOpacity, onLoadStart]); + + const handleLoad = useCallback((data: OnLoadData) => { + setIsLoading(false); + loadingOpacity.value = withTiming(0, { duration: 300 }); + setDuration(data.duration * 1000); // Convert to milliseconds + onLoad?.(); + logger.info('TrailerPlayer', 'Video loaded successfully'); + }, [loadingOpacity, onLoad]); + + const handleError = useCallback((error: string) => { + setIsLoading(false); + setHasError(true); + loadingOpacity.value = withTiming(0, { duration: 300 }); + onError?.(error); + logger.error('TrailerPlayer', 'Video error:', error); + }, [loadingOpacity, onError]); + + const handleProgress = useCallback((data: OnProgressData) => { + setPosition(data.currentTime * 1000); // Convert to milliseconds + onProgress?.(data); + }, [onProgress]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (hideControlsTimeout.current) { + clearTimeout(hideControlsTimeout.current); + } + }; + }, []); + + // Animated styles + const controlsAnimatedStyle = useAnimatedStyle(() => ({ + opacity: controlsOpacity.value, + })); + + const loadingAnimatedStyle = useAnimatedStyle(() => ({ + opacity: loadingOpacity.value, + })); + + const playButtonAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: playButtonScale.value }], + })); + + const progressPercentage = duration > 0 ? (position / duration) * 100 : 0; + + if (hasError) { + return ( + + + + + + ); + } + + return ( + + + ); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000', + overflow: 'hidden', + }, + video: { + flex: 1, + width: '100%', + height: '100%', + }, + videoOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + loadingContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0,0,0,0.5)', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#000', + }, + controlsContainer: { + flex: 1, + justifyContent: 'space-between', + }, + topGradient: { + height: 100, + width: '100%', + }, + centerControls: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + playButton: { + width: isTablet ? 100 : 80, + height: isTablet ? 100 : 80, + borderRadius: isTablet ? 50 : 40, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 2, + borderColor: 'rgba(255,255,255,0.8)', + }, + bottomGradient: { + paddingBottom: Platform.OS === 'ios' ? 20 : 16, + paddingTop: 20, + }, + bottomControls: { + paddingHorizontal: isTablet ? 32 : 16, + }, + progressContainer: { + marginBottom: 16, + }, + progressBar: { + height: 3, + backgroundColor: 'rgba(255,255,255,0.3)', + borderRadius: 1.5, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + backgroundColor: '#fff', + borderRadius: 1.5, + }, + controlButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + controlButton: { + padding: 8, + borderRadius: 20, + backgroundColor: 'rgba(0,0,0,0.4)', + }, +}); + +export default TrailerPlayer; \ No newline at end of file diff --git a/src/services/trailerService.ts b/src/services/trailerService.ts new file mode 100644 index 00000000..d4dd007a --- /dev/null +++ b/src/services/trailerService.ts @@ -0,0 +1,166 @@ +import { logger } from '../utils/logger'; + +export interface TrailerData { + url: string; + title: string; + year: number; +} + +export class TrailerService { + private static readonly BASE_URL = 'https://db.xprime.tv/trailers'; + private static readonly TIMEOUT = 10000; // 10 seconds + + /** + * Fetches trailer URL for a given title and year + * @param title - The movie/series title + * @param year - The release year + * @returns Promise - The trailer URL or null if not found + */ + static async getTrailerUrl(title: string, year: number): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT); + + const url = `${this.BASE_URL}?title=${encodeURIComponent(title)}&year=${year}`; + + logger.info('TrailerService', `Fetching trailer for: ${title} (${year})`); + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'text/plain', + 'User-Agent': 'Nuvio/1.0', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.warn('TrailerService', `Failed to fetch trailer: ${response.status} ${response.statusText}`); + return null; + } + + const trailerUrl = await response.text(); + + // Validate the response is a valid URL + if (!trailerUrl || !this.isValidTrailerUrl(trailerUrl.trim())) { + logger.warn('TrailerService', `Invalid trailer URL received: ${trailerUrl}`); + return null; + } + + const cleanUrl = trailerUrl.trim(); + logger.info('TrailerService', `Successfully fetched trailer URL: ${cleanUrl}`); + + return cleanUrl; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + logger.warn('TrailerService', 'Trailer fetch request timed out'); + } else { + logger.error('TrailerService', 'Error fetching trailer:', error); + } + return null; + } + } + + /** + * Validates if the provided string is a valid trailer URL + * @param url - The URL to validate + * @returns boolean - True if valid, false otherwise + */ + private static isValidTrailerUrl(url: string): boolean { + try { + const urlObj = new URL(url); + + // Check if it's a valid HTTP/HTTPS URL + if (!['http:', 'https:'].includes(urlObj.protocol)) { + return false; + } + + // Check for common video streaming domains/patterns + const validDomains = [ + 'theplatform.com', + 'youtube.com', + 'youtu.be', + 'vimeo.com', + 'dailymotion.com', + 'twitch.tv', + 'amazonaws.com', + 'cloudfront.net' + ]; + + const hostname = urlObj.hostname.toLowerCase(); + const isValidDomain = validDomains.some(domain => + hostname.includes(domain) || hostname.endsWith(domain) + ); + + // Check for video file extensions or streaming formats + const hasVideoFormat = /\.(mp4|m3u8|mpd|webm|mov|avi|mkv)$/i.test(urlObj.pathname) || + url.includes('formats=') || + url.includes('manifest') || + url.includes('playlist'); + + return isValidDomain || hasVideoFormat; + } catch { + return false; + } + } + + /** + * Extracts the best video format URL from a multi-format URL + * @param url - The trailer URL that may contain multiple formats + * @returns string - The best format URL for mobile playback + */ + static getBestFormatUrl(url: string): string { + // If the URL contains format parameters, try to get the best one for mobile + if (url.includes('formats=')) { + // Prefer M3U (HLS) for better mobile compatibility + if (url.includes('M3U')) { + // Try to get M3U without encryption first, then with encryption + const baseUrl = url.split('?')[0]; + return `${baseUrl}?formats=M3U+none,M3U+appleHlsEncryption`; + } + // Fallback to MP4 if available + if (url.includes('MPEG4')) { + const baseUrl = url.split('?')[0]; + return `${baseUrl}?formats=MPEG4`; + } + } + + // Return the original URL if no format optimization is needed + return url; + } + + /** + * Checks if a trailer is available for the given title and year + * @param title - The movie/series title + * @param year - The release year + * @returns Promise - True if trailer is available + */ + static async isTrailerAvailable(title: string, year: number): Promise { + const trailerUrl = await this.getTrailerUrl(title, year); + return trailerUrl !== null; + } + + /** + * Gets trailer data with additional metadata + * @param title - The movie/series title + * @param year - The release year + * @returns Promise - Trailer data or null if not found + */ + static async getTrailerData(title: string, year: number): Promise { + const url = await this.getTrailerUrl(title, year); + + if (!url) { + return null; + } + + return { + url: this.getBestFormatUrl(url), + title, + year + }; + } +} + +export default TrailerService; \ No newline at end of file