mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
trailer section init
This commit is contained in:
parent
e435a68aea
commit
e9d54bf0d6
5 changed files with 908 additions and 0 deletions
1
TrailerService
Submodule
1
TrailerService
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 2cb2c6d1a3ca60416160bdb28be800fe249207fc
|
||||
352
src/components/metadata/TrailerModal.tsx
Normal file
352
src/components/metadata/TrailerModal.tsx
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import React, { useState, useEffect, useCallback, memo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
import TrailerService from '../../services/trailerService';
|
||||
import TrailerPlayer from '../video/TrailerPlayer';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
||||
interface TrailerVideo {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
site: string;
|
||||
size: number;
|
||||
type: string;
|
||||
official: boolean;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
interface TrailerModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
trailer: TrailerVideo | null;
|
||||
contentTitle: string;
|
||||
}
|
||||
|
||||
const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
||||
visible,
|
||||
onClose,
|
||||
trailer,
|
||||
contentTitle
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
// Load trailer when modal opens or trailer changes
|
||||
useEffect(() => {
|
||||
if (visible && trailer) {
|
||||
loadTrailer();
|
||||
} else {
|
||||
// Reset state when modal closes
|
||||
setTrailerUrl(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setIsPlaying(false);
|
||||
}
|
||||
}, [visible, trailer]);
|
||||
|
||||
const loadTrailer = useCallback(async () => {
|
||||
if (!trailer) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setTrailerUrl(null);
|
||||
|
||||
try {
|
||||
const youtubeUrl = `https://www.youtube.com/watch?v=${trailer.key}`;
|
||||
|
||||
logger.info('TrailerModal', `Loading trailer: ${trailer.name} (${youtubeUrl})`);
|
||||
|
||||
// Use the direct YouTube URL method - much more efficient!
|
||||
const directUrl = await TrailerService.getTrailerFromYouTubeUrl(
|
||||
youtubeUrl,
|
||||
`${contentTitle} - ${trailer.name}`,
|
||||
new Date(trailer.published_at).getFullYear().toString()
|
||||
);
|
||||
|
||||
if (directUrl) {
|
||||
setTrailerUrl(directUrl);
|
||||
setIsPlaying(true);
|
||||
logger.info('TrailerModal', `Successfully loaded direct trailer URL for: ${trailer.name}`);
|
||||
} else {
|
||||
throw new Error('No streaming URL available');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load trailer';
|
||||
setError(errorMessage);
|
||||
logger.error('TrailerModal', 'Error loading trailer:', err);
|
||||
|
||||
Alert.alert(
|
||||
'Trailer Unavailable',
|
||||
'This trailer could not be loaded at this time. Please try again later.',
|
||||
[{ text: 'OK', style: 'default' }]
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [trailer, contentTitle]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleTrailerError = useCallback(() => {
|
||||
setError('Failed to play trailer');
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
const handleTrailerEnd = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
if (!visible || !trailer) return null;
|
||||
|
||||
const modalHeight = isTablet ? height * 0.8 : height * 0.7;
|
||||
const modalWidth = isTablet ? width * 0.8 : width * 0.95;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={handleClose}
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={[styles.modal, {
|
||||
width: modalWidth,
|
||||
maxHeight: modalHeight,
|
||||
backgroundColor: currentTheme.colors.background
|
||||
}]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleContainer}>
|
||||
<MaterialIcons
|
||||
name="movie"
|
||||
size={20}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.title, { color: currentTheme.colors.highEmphasis }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{trailer.name}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleClose}
|
||||
style={styles.closeButton}
|
||||
hitSlop={{ top: 10, left: 10, right: 10, bottom: 10 }}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="close"
|
||||
size={24}
|
||||
color={currentTheme.colors.highEmphasis}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Trailer Info */}
|
||||
<View style={styles.infoContainer}>
|
||||
<Text style={[styles.meta, { color: currentTheme.colors.textMuted }]}>
|
||||
{trailer.type} • {new Date(trailer.published_at).getFullYear()}
|
||||
{trailer.official && ' • Official'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Player Container */}
|
||||
<View style={styles.playerContainer}>
|
||||
{loading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>
|
||||
Loading trailer...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialIcons
|
||||
name="error-outline"
|
||||
size={48}
|
||||
color={currentTheme.colors.error || '#FF6B6B'}
|
||||
/>
|
||||
<Text style={[styles.errorText, { color: currentTheme.colors.textMuted }]}>
|
||||
{error}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={loadTrailer}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{trailerUrl && !loading && !error && (
|
||||
<View style={styles.playerWrapper}>
|
||||
<TrailerPlayer
|
||||
trailerUrl={trailerUrl}
|
||||
autoPlay={isPlaying}
|
||||
muted={false} // Allow sound in modal
|
||||
style={styles.player}
|
||||
hideLoadingSpinner={true}
|
||||
onLoad={() => logger.info('TrailerModal', 'Trailer loaded successfully')}
|
||||
onError={handleTrailerError}
|
||||
onEnd={handleTrailerEnd}
|
||||
onPlaybackStatusUpdate={(status) => {
|
||||
if (status.isLoaded && !isPlaying) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={[styles.footerText, { color: currentTheme.colors.textMuted }]}>
|
||||
{contentTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.9)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modal: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
elevation: 10,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
marginLeft: 8,
|
||||
},
|
||||
infoContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
meta: {
|
||||
fontSize: 12,
|
||||
opacity: 0.8,
|
||||
},
|
||||
playerContainer: {
|
||||
aspectRatio: 16 / 9,
|
||||
backgroundColor: '#000',
|
||||
position: 'relative',
|
||||
},
|
||||
loadingContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#000',
|
||||
gap: 16,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
opacity: 0.8,
|
||||
},
|
||||
errorContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#000',
|
||||
padding: 20,
|
||||
gap: 16,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
},
|
||||
retryButton: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
playerWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
player: {
|
||||
flex: 1,
|
||||
},
|
||||
footer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 12,
|
||||
opacity: 0.6,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default TrailerModal;
|
||||
485
src/components/metadata/TrailersSection.tsx
Normal file
485
src/components/metadata/TrailersSection.tsx
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
import React, { useState, useEffect, memo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
Alert,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
import TrailerService from '../../services/trailerService';
|
||||
import TrailerModal from './TrailerModal';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
||||
interface TrailerVideo {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
site: string;
|
||||
size: number;
|
||||
type: string;
|
||||
official: boolean;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
interface TrailersSectionProps {
|
||||
tmdbId: number | null;
|
||||
type: 'movie' | 'tv';
|
||||
contentId: string;
|
||||
contentTitle: string;
|
||||
}
|
||||
|
||||
interface CategorizedTrailers {
|
||||
[key: string]: TrailerVideo[];
|
||||
}
|
||||
|
||||
const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||
tmdbId,
|
||||
type,
|
||||
contentId,
|
||||
contentTitle
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const [trailers, setTrailers] = useState<CategorizedTrailers>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTrailer, setSelectedTrailer] = useState<TrailerVideo | null>(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
|
||||
// Fetch trailers from TMDB
|
||||
useEffect(() => {
|
||||
if (!tmdbId) return;
|
||||
|
||||
const fetchTrailers = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
logger.info('TrailersSection', `Fetching trailers for TMDB ID: ${tmdbId}, type: ${type}`);
|
||||
|
||||
// First check if the movie/TV show exists
|
||||
const basicEndpoint = type === 'movie'
|
||||
? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`
|
||||
: `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`;
|
||||
|
||||
const basicResponse = await fetch(basicEndpoint);
|
||||
if (!basicResponse.ok) {
|
||||
logger.error('TrailersSection', `TMDB ID ${tmdbId} not found: ${basicResponse.status}`);
|
||||
setError(`Content not found (TMDB ID: ${tmdbId})`);
|
||||
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`;
|
||||
|
||||
logger.info('TrailersSection', `Fetching 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 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}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
logger.info('TrailersSection', `Received ${data.results?.length || 0} videos for TMDB ID ${tmdbId}`);
|
||||
|
||||
const categorized = categorizeTrailers(data.results || []);
|
||||
const totalVideos = Object.values(categorized).reduce((sum, videos) => sum + videos.length, 0);
|
||||
|
||||
if (totalVideos === 0) {
|
||||
logger.info('TrailersSection', `No videos found for TMDB ID ${tmdbId} - this is normal`);
|
||||
setTrailers({}); // No trailers available
|
||||
} else {
|
||||
logger.info('TrailersSection', `Categorized ${totalVideos} videos into ${Object.keys(categorized).length} categories`);
|
||||
setTrailers(categorized);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load trailers';
|
||||
setError(errorMessage);
|
||||
logger.error('TrailersSection', 'Error fetching trailers:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTrailers();
|
||||
}, [tmdbId, type]);
|
||||
|
||||
// Categorize trailers by type
|
||||
const categorizeTrailers = (videos: any[]): CategorizedTrailers => {
|
||||
const categories: CategorizedTrailers = {};
|
||||
|
||||
videos.forEach(video => {
|
||||
if (video.site !== 'YouTube') return; // Only YouTube videos
|
||||
|
||||
const category = video.type;
|
||||
if (!categories[category]) {
|
||||
categories[category] = [];
|
||||
}
|
||||
categories[category].push(video);
|
||||
});
|
||||
|
||||
// Sort within each category by published date (newest first)
|
||||
Object.keys(categories).forEach(category => {
|
||||
categories[category].sort((a, b) =>
|
||||
new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
// Handle trailer selection
|
||||
const handleTrailerPress = (trailer: TrailerVideo) => {
|
||||
setSelectedTrailer(trailer);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Handle modal close
|
||||
const handleModalClose = () => {
|
||||
setModalVisible(false);
|
||||
setSelectedTrailer(null);
|
||||
};
|
||||
|
||||
// Get thumbnail URL for YouTube video
|
||||
const getYouTubeThumbnail = (videoId: string, quality: 'default' | 'hq' | 'maxres' = 'hq') => {
|
||||
const qualities = {
|
||||
default: `https://img.youtube.com/vi/${videoId}/default.jpg`,
|
||||
hq: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`,
|
||||
maxres: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`
|
||||
};
|
||||
return qualities[quality];
|
||||
};
|
||||
|
||||
// Format trailer type for display
|
||||
const formatTrailerType = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'Trailer':
|
||||
return 'Official Trailers';
|
||||
case 'Teaser':
|
||||
return 'Teasers';
|
||||
case 'Clip':
|
||||
return 'Clips & Scenes';
|
||||
case 'Featurette':
|
||||
return 'Featurettes';
|
||||
case 'Behind the Scenes':
|
||||
return 'Behind the Scenes';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon for trailer type
|
||||
const getTrailerTypeIcon = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'Trailer':
|
||||
return 'movie';
|
||||
case 'Teaser':
|
||||
return 'videocam';
|
||||
case 'Clip':
|
||||
return 'content-cut';
|
||||
case 'Featurette':
|
||||
return 'featured-video';
|
||||
case 'Behind the Scenes':
|
||||
return 'camera';
|
||||
default:
|
||||
return 'play-circle-outline';
|
||||
}
|
||||
};
|
||||
|
||||
if (!tmdbId) {
|
||||
return null; // Don't show if no TMDB ID
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Trailers
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>
|
||||
Loading trailers...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Trailers
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialIcons name="error-outline" size={24} color={currentTheme.colors.error || '#FF6B6B'} />
|
||||
<Text style={[styles.errorText, { color: currentTheme.colors.textMuted }]}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const trailerCategories = Object.keys(trailers);
|
||||
const totalVideos = Object.values(trailers).reduce((sum, videos) => sum + videos.length, 0);
|
||||
|
||||
// Don't show section if no trailers (this is normal for many movies/TV shows)
|
||||
if (trailerCategories.length === 0 || totalVideos === 0) {
|
||||
// In development, show a subtle indicator that the section checked but found no trailers
|
||||
if (__DEV__) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Trailers
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.noTrailersContainer}>
|
||||
<Text style={[styles.noTrailersText, { color: currentTheme.colors.textMuted }]}>
|
||||
No trailers available
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Trailers
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{trailerCategories.map(category => (
|
||||
<View key={category} style={styles.categoryContainer}>
|
||||
<View style={styles.categoryHeader}>
|
||||
<MaterialIcons
|
||||
name={getTrailerTypeIcon(category) as any}
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text style={[styles.categoryTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{formatTrailerType(category)}
|
||||
</Text>
|
||||
<Text style={[styles.categoryCount, { color: currentTheme.colors.textMuted }]}>
|
||||
{trailers[category].length}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.trailersGrid}>
|
||||
{trailers[category].map(trailer => {
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={trailer.id}
|
||||
style={styles.trailerCard}
|
||||
onPress={() => handleTrailerPress(trailer)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.thumbnailContainer}>
|
||||
<FastImage
|
||||
source={{ uri: getYouTubeThumbnail(trailer.key, 'hq') }}
|
||||
style={styles.thumbnail}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<View style={styles.playOverlay}>
|
||||
<MaterialIcons name="play-arrow" size={32} color="#fff" />
|
||||
</View>
|
||||
<View style={styles.durationBadge}>
|
||||
<Text style={styles.durationText}>
|
||||
{trailer.size}p
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.trailerInfo}>
|
||||
<Text
|
||||
style={[styles.trailerTitle, { color: currentTheme.colors.highEmphasis }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{trailer.name}
|
||||
</Text>
|
||||
<Text style={[styles.trailerMeta, { color: currentTheme.colors.textMuted }]}>
|
||||
{new Date(trailer.published_at).getFullYear()}
|
||||
{trailer.official && ' • Official'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Trailer Modal */}
|
||||
<TrailerModal
|
||||
visible={modalVisible}
|
||||
onClose={handleModalClose}
|
||||
trailer={selectedTrailer}
|
||||
contentTitle={contentTitle}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: 16,
|
||||
marginTop: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
gap: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
opacity: 0.9,
|
||||
},
|
||||
loadingContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 32,
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
},
|
||||
errorContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
gap: 8,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
},
|
||||
categoryContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
categoryHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
},
|
||||
categoryTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
categoryCount: {
|
||||
fontSize: 12,
|
||||
opacity: 0.6,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 10,
|
||||
},
|
||||
trailersGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
},
|
||||
trailerCard: {
|
||||
width: isTablet ? (width - 32 - 24) / 3 : (width - 32 - 12) / 2,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thumbnailContainer: {
|
||||
position: 'relative',
|
||||
aspectRatio: 16 / 9,
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
playOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
durationBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
durationText: {
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
trailerInfo: {
|
||||
padding: 12,
|
||||
},
|
||||
trailerTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
lineHeight: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
trailerMeta: {
|
||||
fontSize: 11,
|
||||
opacity: 0.7,
|
||||
},
|
||||
noTrailersContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
},
|
||||
noTrailersText: {
|
||||
fontSize: 14,
|
||||
opacity: 0.6,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
export default TrailersSection;
|
||||
|
|
@ -26,6 +26,7 @@ import { MovieContent } from '../components/metadata/MovieContent';
|
|||
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
||||
import { RatingsSection } from '../components/metadata/RatingsSection';
|
||||
import { CommentsSection, CommentBottomSheet } from '../components/metadata/CommentsSection';
|
||||
import TrailersSection from '../components/metadata/TrailersSection';
|
||||
import { RouteParams, Episode } from '../types/metadata';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
|
|
@ -992,6 +993,16 @@ const MetadataScreen: React.FC = () => {
|
|||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Trailers Section - Lazy loaded */}
|
||||
{shouldLoadSecondaryData && tmdbId && settings.enrichMetadataWithTMDB && (
|
||||
<TrailersSection
|
||||
tmdbId={tmdbId}
|
||||
type={Object.keys(groupedEpisodes).length > 0 ? 'tv' : 'movie'}
|
||||
contentId={id}
|
||||
contentTitle={metadata?.name || (metadata as any)?.title || 'Unknown'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Comments Section - Lazy loaded */}
|
||||
{shouldLoadSecondaryData && imdbId && (
|
||||
<MemoizedCommentsSection
|
||||
|
|
|
|||
|
|
@ -317,6 +317,65 @@ export class TrailerService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches trailer directly from a known YouTube URL
|
||||
* @param youtubeUrl - The YouTube URL to process
|
||||
* @param title - Optional title for logging/caching
|
||||
* @param year - Optional year for logging/caching
|
||||
* @returns Promise<string | null> - The direct streaming URL or null if failed
|
||||
*/
|
||||
static async getTrailerFromYouTubeUrl(youtubeUrl: string, title?: string, year?: string): Promise<string | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('youtube_url', youtubeUrl);
|
||||
if (title) params.append('title', title);
|
||||
if (year) params.append('year', year.toString());
|
||||
|
||||
const url = `${this.ENV_LOCAL_BASE}${this.ENV_LOCAL_TRAILER_PATH}?${params.toString()}`;
|
||||
logger.info('TrailerService', `Fetching trailer directly from YouTube URL: ${youtubeUrl}`);
|
||||
logger.info('TrailerService', `Direct trailer request URL: ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Nuvio/1.0',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
logger.info('TrailerService', `Direct trailer response: status=${response.status} ok=${response.ok}`);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('TrailerService', `Direct trailer failed: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.url || !this.isValidTrailerUrl(data.url)) {
|
||||
logger.warn('TrailerService', `Invalid trailer URL from direct fetch: ${data.url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('TrailerService', `Successfully got direct trailer: ${String(data.url).substring(0, 80)}...`);
|
||||
return data.url;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
logger.warn('TrailerService', `Direct trailer request timed out after ${this.TIMEOUT}ms`);
|
||||
} else {
|
||||
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
logger.error('TrailerService', `Error in direct trailer fetch: ${msg}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch between local server and XPrime API
|
||||
* @param useLocal - true for local server, false for XPrime
|
||||
|
|
|
|||
Loading…
Reference in a new issue