diff --git a/TrailerService b/TrailerService new file mode 160000 index 0000000..2cb2c6d --- /dev/null +++ b/TrailerService @@ -0,0 +1 @@ +Subproject commit 2cb2c6d1a3ca60416160bdb28be800fe249207fc diff --git a/src/components/metadata/TrailerModal.tsx b/src/components/metadata/TrailerModal.tsx new file mode 100644 index 0000000..c23f6a4 --- /dev/null +++ b/src/components/metadata/TrailerModal.tsx @@ -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 = memo(({ + visible, + onClose, + trailer, + contentTitle +}) => { + const { currentTheme } = useTheme(); + const [trailerUrl, setTrailerUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( + + + + {/* Header */} + + + + + {trailer.name} + + + + + + + + {/* Trailer Info */} + + + {trailer.type} • {new Date(trailer.published_at).getFullYear()} + {trailer.official && ' • Official'} + + + + {/* Player Container */} + + {loading && ( + + + + Loading trailer... + + + )} + + {error && !loading && ( + + + + {error} + + + Try Again + + + )} + + {trailerUrl && !loading && !error && ( + + logger.info('TrailerModal', 'Trailer loaded successfully')} + onError={handleTrailerError} + onEnd={handleTrailerEnd} + onPlaybackStatusUpdate={(status) => { + if (status.isLoaded && !isPlaying) { + setIsPlaying(true); + } + }} + /> + + )} + + + {/* Footer */} + + + {contentTitle} + + + + + + ); +}); + +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; diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx new file mode 100644 index 0000000..935db61 --- /dev/null +++ b/src/components/metadata/TrailersSection.tsx @@ -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 = memo(({ + tmdbId, + type, + contentId, + contentTitle +}) => { + const { currentTheme } = useTheme(); + const [trailers, setTrailers] = useState({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedTrailer, setSelectedTrailer] = useState(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 ( + + + + + Trailers + + + + + + Loading trailers... + + + + ); + } + + if (error) { + return ( + + + + + Trailers + + + + + + {error} + + + + ); + } + + 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 ( + + + + + Trailers + + + + + No trailers available + + + + ); + } + return null; + } + + return ( + + + + + Trailers + + + + {trailerCategories.map(category => ( + + + + + {formatTrailerType(category)} + + + {trailers[category].length} + + + + + {trailers[category].map(trailer => { + + return ( + handleTrailerPress(trailer)} + activeOpacity={0.8} + > + + + + + + + + {trailer.size}p + + + + + + + {trailer.name} + + + {new Date(trailer.published_at).getFullYear()} + {trailer.official && ' • Official'} + + + + ); + })} + + + ))} + + {/* Trailer Modal */} + + + ); +}); + +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; diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index d37128a..a922d8c 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -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 = () => { )} + {/* Trailers Section - Lazy loaded */} + {shouldLoadSecondaryData && tmdbId && settings.enrichMetadataWithTMDB && ( + 0 ? 'tv' : 'movie'} + contentId={id} + contentTitle={metadata?.name || (metadata as any)?.title || 'Unknown'} + /> + )} + {/* Comments Section - Lazy loaded */} {shouldLoadSecondaryData && imdbId && ( - The direct streaming URL or null if failed + */ + static async getTrailerFromYouTubeUrl(youtubeUrl: string, title?: string, year?: string): Promise { + 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