mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-22 01:01:56 +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 { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
||||||
import { RatingsSection } from '../components/metadata/RatingsSection';
|
import { RatingsSection } from '../components/metadata/RatingsSection';
|
||||||
import { CommentsSection, CommentBottomSheet } from '../components/metadata/CommentsSection';
|
import { CommentsSection, CommentBottomSheet } from '../components/metadata/CommentsSection';
|
||||||
|
import TrailersSection from '../components/metadata/TrailersSection';
|
||||||
import { RouteParams, Episode } from '../types/metadata';
|
import { RouteParams, Episode } from '../types/metadata';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
|
|
@ -992,6 +993,16 @@ const MetadataScreen: React.FC = () => {
|
||||||
</Animated.View>
|
</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 */}
|
{/* Comments Section - Lazy loaded */}
|
||||||
{shouldLoadSecondaryData && imdbId && (
|
{shouldLoadSecondaryData && imdbId && (
|
||||||
<MemoizedCommentsSection
|
<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
|
* Switch between local server and XPrime API
|
||||||
* @param useLocal - true for local server, false for XPrime
|
* @param useLocal - true for local server, false for XPrime
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue