mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-21 00:32:04 +00:00
trailer ininital addition
This commit is contained in:
parent
5b71ccc56c
commit
face30c163
3 changed files with 587 additions and 4 deletions
|
|
@ -29,6 +29,8 @@ import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { useTraktContext } from '../../contexts/TraktContext';
|
import { useTraktContext } from '../../contexts/TraktContext';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { TMDBService } from '../../services/tmdbService';
|
import { TMDBService } from '../../services/tmdbService';
|
||||||
|
import TrailerService from '../../services/trailerService';
|
||||||
|
import TrailerPlayer from '../video/TrailerPlayer';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
const isTablet = width >= 768;
|
const isTablet = width >= 768;
|
||||||
|
|
@ -693,6 +695,11 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
// Image loading state with optimized management
|
// Image loading state with optimized management
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [imageLoaded, setImageLoaded] = 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 imageOpacity = useSharedValue(1);
|
||||||
const imageLoadOpacity = useSharedValue(0);
|
const imageLoadOpacity = useSharedValue(0);
|
||||||
const shimmerOpacity = useSharedValue(0.3);
|
const shimmerOpacity = useSharedValue(0.3);
|
||||||
|
|
@ -722,6 +729,33 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
return () => timer.cancel();
|
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
|
// Optimized shimmer animation for loading state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldLoadSecondaryData) return;
|
if (!shouldLoadSecondaryData) return;
|
||||||
|
|
@ -903,7 +937,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
<View style={[styles.absoluteFill, { backgroundColor: themeColors.black }]} />
|
<View style={[styles.absoluteFill, { backgroundColor: themeColors.black }]} />
|
||||||
|
|
||||||
{/* Optimized shimmer loading effect */}
|
{/* Optimized shimmer loading effect */}
|
||||||
{shouldLoadSecondaryData && ((imageSource && !imageLoaded) || loadingBanner) && (
|
{shouldLoadSecondaryData && (trailerLoading || ((imageSource && !imageLoaded) || loadingBanner)) && (
|
||||||
<Animated.View style={[styles.absoluteFill, {
|
<Animated.View style={[styles.absoluteFill, {
|
||||||
opacity: shimmerOpacity,
|
opacity: shimmerOpacity,
|
||||||
}]}>
|
}]}>
|
||||||
|
|
@ -916,8 +950,24 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Optimized background image with lazy loading */}
|
{/* Trailer player or background image */}
|
||||||
{shouldLoadSecondaryData && imageSource && !loadingBanner && (
|
{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
|
<Animated.Image
|
||||||
source={{ uri: imageSource }}
|
source={{ uri: imageSource }}
|
||||||
style={[styles.absoluteFill, backdropImageStyle]}
|
style={[styles.absoluteFill, backdropImageStyle]}
|
||||||
|
|
@ -925,7 +975,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<Animated.View style={styles.backButtonContainer}>
|
<Animated.View style={styles.backButtonContainer}>
|
||||||
<TouchableOpacity style={styles.backButton} onPress={handleBack}>
|
<TouchableOpacity style={styles.backButton} onPress={handleBack}>
|
||||||
|
|
|
||||||
367
src/components/video/TrailerPlayer.tsx
Normal file
367
src/components/video/TrailerPlayer.tsx
Normal 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;
|
||||||
166
src/services/trailerService.ts
Normal file
166
src/services/trailerService.ts
Normal 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;
|
||||||
Loading…
Reference in a new issue