trailer ininital addition

This commit is contained in:
tapframe 2025-08-31 12:53:32 +05:30
parent 5b71ccc56c
commit face30c163
3 changed files with 587 additions and 4 deletions

View file

@ -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<HeroSectionProps> = memo(({
// Image loading state with optimized management
const [imageError, setImageError] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [trailerUrl, setTrailerUrl] = useState<string | null>(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<HeroSectionProps> = 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<HeroSectionProps> = memo(({
<View style={[styles.absoluteFill, { backgroundColor: themeColors.black }]} />
{/* Optimized shimmer loading effect */}
{shouldLoadSecondaryData && ((imageSource && !imageLoaded) || loadingBanner) && (
{shouldLoadSecondaryData && (trailerLoading || ((imageSource && !imageLoaded) || loadingBanner)) && (
<Animated.View style={[styles.absoluteFill, {
opacity: shimmerOpacity,
}]}>
@ -916,8 +950,24 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
</Animated.View>
)}
{/* Optimized background image with lazy loading */}
{shouldLoadSecondaryData && imageSource && !loadingBanner && (
{/* Trailer player or background image */}
{shouldLoadSecondaryData && trailerUrl && !trailerLoading && !trailerError ? (
<TrailerPlayer
trailerUrl={trailerUrl}
autoPlay={true}
muted={trailerMuted}
style={styles.absoluteFill}
onError={() => {
logger.warn('HeroSection', 'Trailer playback failed, falling back to image');
setTrailerError(true);
}}
onProgress={() => {
if (!trailerPlaying) {
setTrailerPlaying(true);
}
}}
/>
) : shouldLoadSecondaryData && imageSource && !loadingBanner ? (
<Animated.Image
source={{ uri: imageSource }}
style={[styles.absoluteFill, backdropImageStyle]}
@ -925,7 +975,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
onError={handleImageError}
onLoad={handleImageLoad}
/>
)}
) : null}
<Animated.View style={styles.backButtonContainer}>
<TouchableOpacity style={styles.backButton} onPress={handleBack}>

View file

@ -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<TrailerPlayerProps> = memo(({
trailerUrl,
autoPlay = true,
muted = true,
onLoadStart,
onLoad,
onError,
onProgress,
style,
}) => {
const { currentTheme } = useTheme();
const videoRef = useRef<VideoRef>(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<NodeJS.Timeout | null>(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 (
<View style={[styles.container, style]}>
<View style={styles.errorContainer}>
<MaterialIcons name="error-outline" size={48} color={currentTheme.colors.error} />
</View>
</View>
);
}
return (
<View style={[styles.container, style]}>
<Video
ref={videoRef}
source={{ uri: trailerUrl }}
style={styles.video}
resizeMode="cover"
paused={!isPlaying}
repeat={true}
muted={isMuted}
onLoadStart={handleLoadStart}
onLoad={handleLoad}
onError={(error: any) => handleError(error?.error?.message || 'Unknown error')}
onProgress={handleProgress}
controls={false}
onEnd={() => {
// Auto-restart when video ends
videoRef.current?.seek(0);
setIsPlaying(true);
}}
/>
{/* Loading indicator */}
<Animated.View style={[styles.loadingContainer, loadingAnimatedStyle]}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
</Animated.View>
{/* Video controls overlay */}
<TouchableOpacity
style={styles.videoOverlay}
onPress={handleVideoPress}
activeOpacity={1}
>
<Animated.View style={[styles.controlsContainer, controlsAnimatedStyle]}>
{/* Top gradient */}
<LinearGradient
colors={['rgba(0,0,0,0.6)', 'transparent']}
style={styles.topGradient}
pointerEvents="none"
/>
{/* Center play/pause button */}
<View style={styles.centerControls}>
<Animated.View style={playButtonAnimatedStyle}>
<TouchableOpacity style={styles.playButton} onPress={handlePlayPause}>
<MaterialIcons
name={isPlaying ? 'pause' : 'play-arrow'}
size={isTablet ? 64 : 48}
color="white"
/>
</TouchableOpacity>
</Animated.View>
</View>
{/* Bottom controls */}
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.8)']}
style={styles.bottomGradient}
>
<View style={styles.bottomControls}>
{/* Progress bar */}
<View style={styles.progressContainer}>
<View style={styles.progressBar}>
<View
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
/>
</View>
</View>
{/* Control buttons */}
<View style={styles.controlButtons}>
<TouchableOpacity style={styles.controlButton} onPress={handlePlayPause}>
<MaterialIcons
name={isPlaying ? 'pause' : 'play-arrow'}
size={isTablet ? 32 : 24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity style={styles.controlButton} onPress={handleMuteToggle}>
<MaterialIcons
name={isMuted ? 'volume-off' : 'volume-up'}
size={isTablet ? 32 : 24}
color="white"
/>
</TouchableOpacity>
</View>
</View>
</LinearGradient>
</Animated.View>
</TouchableOpacity>
</View>
);
});
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;

View file

@ -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<string | null> - The trailer URL or null if not found
*/
static async getTrailerUrl(title: string, year: number): Promise<string | null> {
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<boolean> - True if trailer is available
*/
static async isTrailerAvailable(title: string, year: number): Promise<boolean> {
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<TrailerData | null> - Trailer data or null if not found
*/
static async getTrailerData(title: string, year: number): Promise<TrailerData | null> {
const url = await this.getTrailerUrl(title, year);
if (!url) {
return null;
}
return {
url: this.getBestFormatUrl(url),
title,
year
};
}
}
export default TrailerService;