diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 8d8c4b8c..16d3e89e 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -77,6 +77,7 @@ export type RootStackParamList = { id?: string; type?: string; episodeId?: string; + imdbId?: string; }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; Credits: { mediaId: string; mediaType: string }; diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 5ac6a52e..81e61aa2 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -270,6 +270,7 @@ export const StreamsScreen = () => { loadEpisodeStreams, setSelectedEpisode, groupedEpisodes, + imdbId, } = useMetadata({ id, type }); // Create styles using current theme colors @@ -526,7 +527,8 @@ export const StreamsScreen = () => { streamProvider: stream.name, id, type, - episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined + episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, + imdbId: imdbId || undefined, }); } catch (error) { logger.error('[StreamsScreen] Error locking orientation before navigation:', error); @@ -542,10 +544,11 @@ export const StreamsScreen = () => { streamProvider: stream.name, id, type, - episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined + episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, + imdbId: imdbId || undefined, }); } - }, [metadata, type, currentEpisode, navigation, id, selectedEpisode]); + }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId]); // Update handleStreamPress const handleStreamPress = useCallback(async (stream: Stream) => { diff --git a/src/screens/VideoPlayer.tsx b/src/screens/VideoPlayer.tsx index 1f65ceef..07fd996c 100644 --- a/src/screens/VideoPlayer.tsx +++ b/src/screens/VideoPlayer.tsx @@ -1,13 +1,13 @@ import React, { useState, useRef, useEffect } from 'react'; -import { View, TouchableOpacity, StyleSheet, Text, Dimensions, Modal, Pressable, StatusBar, Platform, ScrollView, Animated, ActivityIndicator } from 'react-native'; +import { View, TouchableOpacity, StyleSheet, Text, Dimensions, Modal, Pressable, StatusBar, Platform, ScrollView, Animated, ActivityIndicator, Image } from 'react-native'; import { VLCPlayer } from 'react-native-vlc-media-player'; import { Ionicons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { useSharedValue, runOnJS, withTiming } from 'react-native-reanimated'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; -// Remove Gesture Handler imports -// import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; +// Add Gesture Handler imports for pinch zoom +import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; // Import for navigation bar hiding import { NativeModules } from 'react-native'; // Import immersive mode package @@ -22,7 +22,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; // Debug flag - set back to false to disable verbose logging // WARNING: Setting this to true currently causes infinite render loops // Use selective logging instead if debugging is needed -const DEBUG_MODE = false; +const DEBUG_MODE = true; // Safer debug function that won't cause render loops // Call this with any debugging info you need instead of using inline DEBUG_MODE checks @@ -69,6 +69,7 @@ interface VideoPlayerProps { id?: string; type?: string; episodeId?: string; + imdbId?: string; // Add IMDb ID for subtitle fetching } // Match the react-native-video AudioTrack type @@ -89,9 +90,9 @@ interface TextTrack { type?: string | null; // Adjusting type based on linter error } -// Define the possible resize modes - adjust to match VLCPlayer's PlayerResizeMode options -type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none'; -const resizeModes: ResizeModeType[] = ['contain', 'cover', 'fill']; +// Define the possible resize modes - force to stretch for absolute full screen +type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none' | 'stretch'; +const resizeModes: ResizeModeType[] = ['stretch']; // Force stretch mode for absolute full screen // Add language code to name mapping const languageMap: {[key: string]: string} = { @@ -187,6 +188,30 @@ const getTrackDisplayName = (track: { name?: string, id: number }): string => { return track.name; }; +// Add subtitle-related constants and types +const SUBTITLE_SIZE_KEY = '@subtitle_size_preference'; +const DEFAULT_SUBTITLE_SIZE = 16; + +interface SubtitleCue { + start: number; + end: number; + text: string; +} + +// Add interface for Wyzie subtitle API response +interface WyzieSubtitle { + id: string; + url: string; + flagUrl: string; + format: string; + encoding: string; + media: string; + display: string; + language: string; + isHearingImpaired: boolean; + source: string; +} + const VideoPlayer: React.FC = () => { const navigation = useNavigation(); const route = useRoute>(); @@ -203,15 +228,20 @@ const VideoPlayer: React.FC = () => { streamProvider, id, type, - episodeId + episodeId, + imdbId } = route.params; // Use safer debug logging for props safeDebugLog("Component mounted with props", { uri, title, season, episode, episodeTitle, quality, year, - streamProvider, id, type, episodeId + streamProvider, id, type, episodeId, imdbId }); + // Get exact screen dimensions + const screenData = Dimensions.get('screen'); // Use 'screen' instead of 'window' to include system UI areas + const [screenDimensions, setScreenDimensions] = useState(screenData); + const [paused, setPaused] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); @@ -221,7 +251,7 @@ const VideoPlayer: React.FC = () => { const [selectedAudioTrack, setSelectedAudioTrack] = useState(null); const [textTracks, setTextTracks] = useState([]); const [selectedTextTrack, setSelectedTextTrack] = useState(-1); // Use -1 for "disabled" - const [resizeMode, setResizeMode] = useState('contain'); // State for resize mode + const [resizeMode, setResizeMode] = useState('stretch'); // Force stretch mode for absolute full screen const [buffered, setBuffered] = useState(0); // Add buffered state const vlcRef = useRef(null); const progress = useSharedValue(0); @@ -279,11 +309,114 @@ const VideoPlayer: React.FC = () => { // Add state for tracking if the video is loaded const [isVideoLoaded, setIsVideoLoaded] = useState(false); + // Add state for tracking video aspect ratio + const [videoAspectRatio, setVideoAspectRatio] = useState(null); + const [is16by9Content, setIs16by9Content] = useState(false); + const [customVideoStyles, setCustomVideoStyles] = useState({}); + + // Add zoom state for pinch gesture + const [zoomScale, setZoomScale] = useState(1); + const [zoomTranslateX, setZoomTranslateX] = useState(0); + const [zoomTranslateY, setZoomTranslateY] = useState(0); + const [lastZoomScale, setLastZoomScale] = useState(1); + const [lastTranslateX, setLastTranslateX] = useState(0); + const [lastTranslateY, setLastTranslateY] = useState(0); + const pinchRef = useRef(null); + + // Add subtitle-related state + const [customSubtitles, setCustomSubtitles] = useState([]); + const [currentSubtitle, setCurrentSubtitle] = useState(''); + const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE); + const [useCustomSubtitles, setUseCustomSubtitles] = useState(false); + const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false); + + // Add Wyzie subtitle states + const [availableSubtitles, setAvailableSubtitles] = useState([]); + const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false); + const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); + + // Calculate custom video styles based on aspect ratios - simplified approach + const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { + // Always return full screen styles - let VLC resize modes handle the rest + return { + position: 'absolute', + top: 0, + left: 0, + width: screenWidth, + height: screenHeight, + backgroundColor: '#000', + }; + }; + + // Pinch gesture handler for zoom functionality - center zoom only, no panning + const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => { + const { scale } = event.nativeEvent; + + // Calculate new scale (limit between 1x and 1.1x) + const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1)); + + // Only apply scale, no translation - always zoom from center + setZoomScale(newScale); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`); + } + }; + + const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => { + if (event.nativeEvent.state === State.END) { + // Save the current scale as the new baseline, no translation + setLastZoomScale(zoomScale); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`); + } + } + }; + + // Reset zoom to appropriate level (1.1x for 16:9, 1x for others) + const resetZoom = () => { + const targetZoom = is16by9Content ? 1.1 : 1; + + setZoomScale(targetZoom); + setLastZoomScale(targetZoom); + // No translation needed for center zoom + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`); + } + }; + + // Recalculate video styles when screen dimensions change + useEffect(() => { + if (videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) { + const styles = calculateVideoStyles( + videoAspectRatio * 1000, // Reconstruct width from aspect ratio + 1000, // Use 1000 as base height + screenDimensions.width, + screenDimensions.height + ); + setCustomVideoStyles(styles); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles); + } + } + }, [screenDimensions, videoAspectRatio]); + // Lock screen to landscape when component mounts useEffect(() => { + // Update screen dimensions when they change (orientation changes) + const subscription = Dimensions.addEventListener('change', ({ screen }) => { + setScreenDimensions(screen); + }); + // Since orientation is now locked before navigation, we can start immediately const initializePlayer = () => { - // Enable immersive mode + // Force StatusBar to be completely hidden + StatusBar.setHidden(true, 'none'); + + // Enable immersive mode with more aggressive settings enableImmersiveMode(); // Start the opening animation immediately @@ -294,6 +427,7 @@ const VideoPlayer: React.FC = () => { // Restore screen orientation and disable immersive mode when component unmounts return () => { + subscription?.remove(); const unlockOrientation = async () => { await ScreenOrientation.unlockAsync(); }; @@ -319,7 +453,7 @@ const VideoPlayer: React.FC = () => { duration: 600, useNativeDriver: true, }), - // Scale up from 80% to 100% + // Scale up from 80% to 100% and ensure it stays at 100% Animated.timing(openingScaleAnim, { toValue: 1, duration: 700, @@ -332,7 +466,9 @@ const VideoPlayer: React.FC = () => { useNativeDriver: true, }), ]).start(() => { - // Animation is complete + // Animation is complete - ensure scale is exactly 1 + openingScaleAnim.setValue(1); + openingFadeAnim.setValue(1); setIsOpeningAnimationComplete(true); // Hide the background overlay completely after animation @@ -523,11 +659,65 @@ const VideoPlayer: React.FC = () => { } }; - // Enhanced onLoad handler to mark player as ready + // Enhanced onLoad handler to detect aspect ratio and mark player as ready const onLoad = (data: any) => { setDuration(data.duration / 1000); // VLC returns duration in milliseconds max.value = data.duration / 1000; + // Calculate and detect aspect ratio with custom styling + if (data.videoSize && data.videoSize.width && data.videoSize.height) { + const aspectRatio = data.videoSize.width / data.videoSize.height; + setVideoAspectRatio(aspectRatio); + + // Check if it's 16:9 content (1.777... ≈ 16/9) + const is16x9 = Math.abs(aspectRatio - (16/9)) < 0.1; + setIs16by9Content(is16x9); + + // Auto-zoom 16:9 content to 1.1x to fill more screen + if (is16x9) { + setZoomScale(1.1); + setLastZoomScale(1.1); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Auto-zoomed 16:9 content to 1.1x`); + } + } else { + // Reset zoom for non-16:9 content + setZoomScale(1); + setLastZoomScale(1); + } + + // Calculate custom video styles for precise control + const styles = calculateVideoStyles( + data.videoSize.width, + data.videoSize.height, + screenDimensions.width, + screenDimensions.height + ); + setCustomVideoStyles(styles); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Video aspect ratio: ${aspectRatio.toFixed(3)} (16:9: ${is16x9})`); + logger.log(`[VideoPlayer] Applied custom styles:`, styles); + } + } else { + // Fallback: assume 16:9 and apply default styles with auto-zoom + setIs16by9Content(true); + setZoomScale(1.1); + setLastZoomScale(1.1); + const defaultStyles = { + position: 'absolute', + top: 0, + left: 0, + width: screenDimensions.width, + height: screenDimensions.height, + }; + setCustomVideoStyles(defaultStyles); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Could not detect video size, using default 16:9 styles with 1.1x zoom`); + } + } + // Mark player as ready for seeking setIsPlayerReady(true); @@ -539,10 +729,22 @@ const VideoPlayer: React.FC = () => { if (DEBUG_MODE) { logger.log(`[VideoPlayer] Video loaded with duration: ${data.duration / 1000}`); + logger.log(`[VideoPlayer] Screen dimensions: ${screenDimensions.width}x${screenDimensions.height}`); + logger.log(`[VideoPlayer] VLC Player custom styles applied`); const methods = Object.keys(vlcRef.current || {}).filter( key => typeof vlcRef.current[key] === 'function' ); logger.log('[VideoPlayer] Available VLC methods:', methods); + + // Log track-related methods specifically + const trackMethods = methods.filter(method => + method.toLowerCase().includes('track') || + method.toLowerCase().includes('audio') || + method.toLowerCase().includes('subtitle') || + method.toLowerCase().includes('text') + ); + logger.log('[VideoPlayer] Track-related VLC methods:', trackMethods); + logger.log('[VideoPlayer] Available audio tracks:', audioTracksFromLoad); logger.log('[VideoPlayer] Available subtitle tracks:', textTracksFromLoad); } @@ -558,6 +760,16 @@ const VideoPlayer: React.FC = () => { } // Subtitles default to disabled (-1) + // Prefer external subtitles: Auto-search for external subtitles if IMDb ID is available + if (imdbId && !customSubtitles.length) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Auto-searching for external subtitles with IMDb ID: ${imdbId}`); + } + setTimeout(() => { + fetchAvailableSubtitles(imdbId); + }, 2000); // Delay to let video start playing first + } + // If we have an initial position to seek to, do it now if (initialPosition !== null && !isInitialSeekComplete) { if (DEBUG_MODE) { @@ -604,27 +816,46 @@ const VideoPlayer: React.FC = () => { } }; - // Toggle through aspect ratio modes + // Custom aspect ratio control - now toggles between 1x and 1.1x zoom const cycleAspectRatio = () => { - const currentIndex = resizeModes.indexOf(resizeMode); - const nextIndex = (currentIndex + 1) % resizeModes.length; + const newZoom = zoomScale === 1.1 ? 1 : 1.1; + + setZoomScale(newZoom); + setZoomTranslateX(0); + setZoomTranslateY(0); + setLastZoomScale(newZoom); + setLastTranslateX(0); + setLastTranslateY(0); + if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Changing aspect ratio from ${resizeMode} to ${resizeModes[nextIndex]}`); + logger.log(`[VideoPlayer] Toggled zoom to ${newZoom}x`); } - setResizeMode(resizeModes[nextIndex]); }; - // Function to enable immersive mode + // Enhanced immersive mode function const enableImmersiveMode = () => { - StatusBar.setHidden(true); + // Force hide status bar immediately without animation + StatusBar.setHidden(true, 'none'); if (Platform.OS === 'android') { - // Full immersive mode - hides both status and navigation bars - // Use setBarMode with 'FullSticky' mode to hide all bars with sticky behavior - RNImmersiveMode.setBarMode('FullSticky'); - - // Alternative: if you want to use fullLayout method (which is in the TypeScript definition) - RNImmersiveMode.fullLayout(true); + // Use multiple methods to ensure complete immersion + try { + // Method 1: RNImmersiveMode + RNImmersiveMode.setBarMode('FullSticky'); + RNImmersiveMode.fullLayout(true); + + // Method 2: Additional native module call if available + if (NativeModules.StatusBarManager) { + NativeModules.StatusBarManager.setHidden(true); + } + } catch (error) { + console.log('Immersive mode error:', error); + } + } + + // For iOS, ensure status bar is hidden + if (Platform.OS === 'ios') { + StatusBar.setHidden(true, 'none'); } }; @@ -816,25 +1047,204 @@ const VideoPlayer: React.FC = () => { // Function to select audio track in VLC const selectAudioTrack = (trackId: number) => { setSelectedAudioTrack(trackId); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Selected audio track ID: ${trackId}`); + } }; // Function to select subtitle track in VLC const selectTextTrack = (trackId: number) => { - setSelectedTextTrack(trackId); + if (trackId === -999) { // Special ID for custom subtitles + setUseCustomSubtitles(true); + setSelectedTextTrack(-1); // Disable VLC subtitles + } else { + setUseCustomSubtitles(false); + setSelectedTextTrack(trackId); + } + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Selected subtitle track ID: ${trackId}, custom: ${trackId === -999}`); + } }; // Update subtitle modal to use VLC subtitle tracks const renderSubtitleModal = () => { if (!showSubtitleModal) return null; + return ( + + + + Subtitle Settings + setShowSubtitleModal(false)} + > + + + + + + + + {/* External Subtitles Section - Priority */} + + External Subtitles + High quality subtitles with size control + + {/* Custom subtitles option - show if loaded */} + {customSubtitles.length > 0 ? ( + { + selectTextTrack(-999); + setShowSubtitleModal(false); + }} + > + + + + + Custom Subtitles + + {customSubtitles.length} cues • Size adjustable + + + {useCustomSubtitles && ( + + + + )} + + ) : null} + + {/* Search for external subtitles */} + { + setShowSubtitleModal(false); + fetchAvailableSubtitles(); + }} + disabled={isLoadingSubtitleList} + > + + {isLoadingSubtitleList ? ( + + ) : ( + + )} + + {isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'} + + + + + + {/* Subtitle Size Controls - Only for custom subtitles */} + {useCustomSubtitles && ( + + Size Control + + + + + + {subtitleSize}px + Font Size + + + + + + + )} + + {/* Built-in Subtitles Section */} + + Built-in Subtitles + System default sizing • No customization + + {/* Off option */} + { + selectTextTrack(-1); + setShowSubtitleModal(false); + }} + > + + + + + Disabled + No subtitles + + {(selectedTextTrack === -1 && !useCustomSubtitles) && ( + + + + )} + + + {/* Available built-in subtitle tracks */} + {vlcTextTracks.length > 0 ? vlcTextTracks.map(track => ( + { + selectTextTrack(track.id); + setShowSubtitleModal(false); + }} + > + + + + + + {getTrackDisplayName(track)} + + + Built-in track • System font size + + + {(selectedTextTrack === track.id && !useCustomSubtitles) && ( + + + + )} + + )) : ( + + + No built-in subtitles available + + )} + + + + + + ); + }; + + // Render subtitle language selection modal + const renderSubtitleLanguageModal = () => { + if (!showSubtitleLanguageModal) return null; + return ( - Subtitles + Select Language setShowSubtitleModal(false)} + onPress={() => setShowSubtitleLanguageModal(false)} > @@ -842,52 +1252,38 @@ const VideoPlayer: React.FC = () => { - {/* Off option with improved design */} - { - selectTextTrack(-1); // -1 typically disables subtitles in VLC - setShowSubtitleModal(false); - }} - > - - Off - - {selectedTextTrack === -1 && ( - - - - )} - - - {/* Available subtitle tracks with improved design */} - {vlcTextTracks.length > 0 ? vlcTextTracks.map(track => ( + {availableSubtitles.length > 0 ? availableSubtitles.map(subtitle => ( { - selectTextTrack(track.id); - setShowSubtitleModal(false); - }} + onPress={() => loadWyzieSubtitle(subtitle)} + disabled={isLoadingSubtitles} > - - - {getTrackDisplayName(track)} - - {(track.name && track.language) && ( - {track.name} - )} - - {selectedTextTrack === track.id && ( - - + + + + + {formatLanguage(subtitle.language)} + + + {subtitle.display} + + + {isLoadingSubtitles && ( + )} )) : ( - No subtitle tracks available + + No subtitles found for this content + )} @@ -1085,8 +1481,330 @@ const VideoPlayer: React.FC = () => { }); }; + // Add subtitle size management functions + const loadSubtitleSize = async () => { + try { + const savedSize = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY); + if (savedSize) { + setSubtitleSize(parseInt(savedSize, 10)); + } + } catch (error) { + logger.error('[VideoPlayer] Error loading subtitle size:', error); + } + }; + + const saveSubtitleSize = async (size: number) => { + try { + await AsyncStorage.setItem(SUBTITLE_SIZE_KEY, size.toString()); + setSubtitleSize(size); + } catch (error) { + logger.error('[VideoPlayer] Error saving subtitle size:', error); + } + }; + + // Enhanced SRT parser function - more robust + const parseSRT = (srtContent: string): SubtitleCue[] => { + const cues: SubtitleCue[] = []; + + if (!srtContent || srtContent.trim().length === 0) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Empty content provided`); + } + return cues; + } + + // Normalize line endings and clean up the content + const normalizedContent = srtContent + .replace(/\r\n/g, '\n') // Convert Windows line endings + .replace(/\r/g, '\n') // Convert Mac line endings + .trim(); + + // Split by double newlines, but also handle cases with multiple empty lines + const blocks = normalizedContent.split(/\n\s*\n/).filter(block => block.trim().length > 0); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Found ${blocks.length} blocks after normalization`); + logger.log(`[VideoPlayer] SRT Parser: First few characters: "${normalizedContent.substring(0, 300)}"`); + } + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i].trim(); + const lines = block.split('\n').map(line => line.trim()).filter(line => line.length > 0); + + if (lines.length >= 3) { + // Find the timestamp line (could be line 1 or 2, depending on numbering) + let timeLineIndex = -1; + let timeMatch = null; + + for (let j = 0; j < Math.min(3, lines.length); j++) { + // More flexible time pattern matching + timeMatch = lines[j].match(/(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})/); + if (timeMatch) { + timeLineIndex = j; + break; + } + } + + if (timeMatch && timeLineIndex !== -1) { + try { + const startTime = + parseInt(timeMatch[1]) * 3600 + + parseInt(timeMatch[2]) * 60 + + parseInt(timeMatch[3]) + + parseInt(timeMatch[4]) / 1000; + + const endTime = + parseInt(timeMatch[5]) * 3600 + + parseInt(timeMatch[6]) * 60 + + parseInt(timeMatch[7]) + + parseInt(timeMatch[8]) / 1000; + + // Get text lines (everything after the timestamp line) + const textLines = lines.slice(timeLineIndex + 1); + if (textLines.length > 0) { + const text = textLines + .join('\n') + .replace(/<[^>]*>/g, '') // Remove HTML tags + .replace(/\{[^}]*\}/g, '') // Remove subtitle formatting tags like {italic} + .replace(/\\N/g, '\n') // Handle \N newlines + .trim(); + + if (text.length > 0) { + cues.push({ + start: startTime, + end: endTime, + text: text + }); + + if (DEBUG_MODE && (i < 5 || cues.length <= 10)) { + logger.log(`[VideoPlayer] SRT Parser: Cue ${cues.length}: ${startTime.toFixed(3)}s-${endTime.toFixed(3)}s: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); + } + } + } + } catch (error) { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Error parsing times for block ${i + 1}: ${error}`); + } + } + } else if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: No valid timestamp found in block ${i + 1}. Lines: ${JSON.stringify(lines.slice(0, 3))}`); + } + } else if (DEBUG_MODE && block.length > 0) { + logger.log(`[VideoPlayer] SRT Parser: Block ${i + 1} has insufficient lines (${lines.length}): "${block.substring(0, 100)}"`); + } + } + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] SRT Parser: Successfully parsed ${cues.length} subtitle cues`); + if (cues.length > 0) { + logger.log(`[VideoPlayer] SRT Parser: Time range: ${cues[0].start.toFixed(1)}s to ${cues[cues.length-1].end.toFixed(1)}s`); + } + } + + return cues; + }; + + // Fetch available subtitles from Wyzie API + const fetchAvailableSubtitles = async (imdbIdParam?: string) => { + const targetImdbId = imdbIdParam || imdbId; + if (!targetImdbId) { + logger.error('[VideoPlayer] No IMDb ID available for subtitle search'); + return; + } + + setIsLoadingSubtitleList(true); + try { + // Build search URL with season and episode parameters for TV shows + let searchUrl = `https://sub.wyzie.ru/search?id=${targetImdbId}&encoding=utf-8&source=all`; + + // Add season and episode parameters if available (for TV shows) + if (season && episode) { + searchUrl += `&season=${season}&episode=${episode}`; + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Searching for subtitles with IMDb ID: ${targetImdbId}, Season: ${season}, Episode: ${episode}`); + } + } else { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Searching for subtitles with IMDb ID: ${targetImdbId} (movie or no season/episode info)`); + } + } + + const response = await fetch(searchUrl); + const subtitles: WyzieSubtitle[] = await response.json(); + + // Filter out duplicates and sort by language + const uniqueSubtitles = subtitles.reduce((acc, current) => { + const exists = acc.find(item => item.language === current.language); + if (!exists) { + acc.push(current); + } + return acc; + }, [] as WyzieSubtitle[]); + + // Sort alphabetically by display name + uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display)); + + setAvailableSubtitles(uniqueSubtitles); + setShowSubtitleLanguageModal(true); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Found ${uniqueSubtitles.length} unique subtitle languages for search`); + } + } catch (error) { + logger.error('[VideoPlayer] Error fetching subtitles from Wyzie API:', error); + } finally { + setIsLoadingSubtitleList(false); + } + }; + + // Load subtitle from selected Wyzie entry + const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => { + setShowSubtitleLanguageModal(false); + setIsLoadingSubtitles(true); + + try { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Loading subtitle: ${subtitle.display} from ${subtitle.url}`); + } + + const response = await fetch(subtitle.url); + const srtContent = await response.text(); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Downloaded subtitle content length: ${srtContent.length} characters`); + logger.log(`[VideoPlayer] First 200 characters of subtitle: ${srtContent.substring(0, 200)}`); + } + + const parsedCues = parseSRT(srtContent); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Parsed ${parsedCues.length} subtitle cues`); + if (parsedCues.length > 0) { + logger.log(`[VideoPlayer] First cue: ${parsedCues[0].start}s-${parsedCues[0].end}s: "${parsedCues[0].text}"`); + logger.log(`[VideoPlayer] Last cue: ${parsedCues[parsedCues.length-1].start}s-${parsedCues[parsedCues.length-1].end}s: "${parsedCues[parsedCues.length-1].text}"`); + } + } + + setCustomSubtitles(parsedCues); + setUseCustomSubtitles(true); + + // Disable VLC's built-in subtitles when using custom ones + setSelectedTextTrack(-1); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Successfully loaded subtitle: useCustomSubtitles=true, customSubtitles.length=${parsedCues.length}`); + } + } catch (error) { + logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error); + } finally { + setIsLoadingSubtitles(false); + } + }; + + // Load external subtitle file (keep for backwards compatibility) + const loadExternalSubtitles = async (subtitleUrl: string) => { + if (!subtitleUrl) return; + + setIsLoadingSubtitles(true); + try { + const response = await fetch(subtitleUrl); + const srtContent = await response.text(); + const parsedCues = parseSRT(srtContent); + setCustomSubtitles(parsedCues); + setUseCustomSubtitles(true); + + // Disable VLC's built-in subtitles when using custom ones + setSelectedTextTrack(-1); + + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Loaded ${parsedCues.length} subtitle cues from external file`); + } + } catch (error) { + logger.error('[VideoPlayer] Error loading external subtitles:', error); + } finally { + setIsLoadingSubtitles(false); + } + }; + + // Update current subtitle based on playback time + useEffect(() => { + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Subtitle useEffect - useCustomSubtitles: ${useCustomSubtitles}, customSubtitles.length: ${customSubtitles.length}, currentTime: ${currentTime.toFixed(3)}`); + + // Show detailed info about subtitle cues for debugging + if (useCustomSubtitles && customSubtitles.length > 0 && customSubtitles.length <= 5) { + logger.log(`[VideoPlayer] All ${customSubtitles.length} subtitle cues:`); + customSubtitles.forEach((cue, index) => { + const isActive = currentTime >= cue.start && currentTime <= cue.end; + logger.log(`[VideoPlayer] Cue ${index + 1}: ${cue.start.toFixed(3)}s-${cue.end.toFixed(3)}s ${isActive ? '(ACTIVE)' : ''}: "${cue.text.substring(0, 50)}${cue.text.length > 50 ? '...' : ''}"`); + }); + } else if (useCustomSubtitles && customSubtitles.length > 5) { + // For larger subtitle files, just show nearby cues + const nearbyCues = customSubtitles.filter(cue => + Math.abs(cue.start - currentTime) <= 10 || Math.abs(cue.end - currentTime) <= 10 + ); + if (nearbyCues.length > 0) { + logger.log(`[VideoPlayer] Nearby subtitle cues (within 10s):`); + nearbyCues.slice(0, 3).forEach((cue, index) => { + const isActive = currentTime >= cue.start && currentTime <= cue.end; + logger.log(`[VideoPlayer] Nearby cue: ${cue.start.toFixed(3)}s-${cue.end.toFixed(3)}s ${isActive ? '(ACTIVE)' : ''}: "${cue.text.substring(0, 50)}${cue.text.length > 50 ? '...' : ''}"`); + }); + } + } + } + + if (!useCustomSubtitles || customSubtitles.length === 0) { + if (currentSubtitle !== '') { + setCurrentSubtitle(''); + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Cleared subtitle - useCustomSubtitles: ${useCustomSubtitles}, customSubtitles.length: ${customSubtitles.length}`); + } + } + return; + } + + const currentCue = customSubtitles.find(cue => + currentTime >= cue.start && currentTime <= cue.end + ); + + const newSubtitle = currentCue ? currentCue.text : ''; + + if (DEBUG_MODE && newSubtitle !== currentSubtitle) { + logger.log(`[VideoPlayer] Subtitle changed from "${currentSubtitle}" to "${newSubtitle}" at time ${currentTime.toFixed(3)}`); + if (currentCue) { + logger.log(`[VideoPlayer] Current cue: ${currentCue.start.toFixed(3)}s - ${currentCue.end.toFixed(3)}s: "${currentCue.text}"`); + } + } + + setCurrentSubtitle(newSubtitle); + }, [currentTime, customSubtitles, useCustomSubtitles]); + + // Load subtitle size preference on mount + useEffect(() => { + loadSubtitleSize(); + }, []); + + // Add subtitle size adjustment functions + const increaseSubtitleSize = () => { + const newSize = Math.min(subtitleSize + 2, 32); + saveSubtitleSize(newSize); + }; + + const decreaseSubtitleSize = () => { + const newSize = Math.max(subtitleSize - 2, 8); + saveSubtitleSize(newSize); + }; + + + return ( - + {/* Opening Animation Overlay - covers the entire screen during transition */} { { opacity: backgroundFadeAnim, zIndex: isOpeningAnimationComplete ? -1 : 3000, + width: screenDimensions.width, + height: screenDimensions.height, } ]} pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'} @@ -1104,38 +1824,86 @@ const VideoPlayer: React.FC = () => { - {/* Animated Video Player Container */} + {/* Animated Video Player Container - ensure no transform issues */} - + + + + + + + {/* Progress bar with enhanced touch handling */} @@ -1238,11 +2006,11 @@ const VideoPlayer: React.FC = () => { Speed ({playbackSpeed}x) - {/* Aspect Ratio Button - Added */} + {/* Fill/Cover Button - Updated to show fill/cover modes */} - - Aspect ({resizeMode}) + + {zoomScale === 1.1 ? 'Fill' : 'Cover'} @@ -1258,15 +2026,16 @@ const VideoPlayer: React.FC = () => { - {/* Subtitle Button - Updated to use vlcTextTracks */} + {/* Subtitle Button - Always available for external subtitle search */} setShowSubtitleModal(true)} - disabled={vlcTextTracks.length === 0} > - - - {(selectedTextTrack === -1) + + + {useCustomSubtitles + ? 'Subtitles: Custom' + : (selectedTextTrack === -1) ? 'Subtitles' : `Subtitles: ${getTrackDisplayName(vlcTextTracks.find(t => t.id === selectedTextTrack) || {id: -1, name: 'On'})}`} @@ -1276,6 +2045,28 @@ const VideoPlayer: React.FC = () => { + {/* Custom Subtitle Overlay - Enhanced visibility and debugging */} + {(useCustomSubtitles && currentSubtitle) && ( + + + + {currentSubtitle} + + + + )} + + {/* Debug subtitle info when controls are visible */} + {DEBUG_MODE && showControls && ( + + + Custom Subs: {useCustomSubtitles ? 'ON' : 'OFF'} | + Cues: {customSubtitles.length} | + Current: "{currentSubtitle}" + + + )} + {/* Resume Overlay */} {showResumeOverlay && resumePosition !== null && ( @@ -1357,44 +2148,64 @@ const VideoPlayer: React.FC = () => { {/* Use the new modal rendering functions */} {renderAudioModal()} {renderSubtitleModal()} + {renderSubtitleLanguageModal()} ); }; const styles = StyleSheet.create({ container: { - flex: 1, backgroundColor: '#000', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, }, videoContainer: { - flex: 1, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, }, video: { - flex: 1, + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, }, controlsContainer: { ...StyleSheet.absoluteFillObject, justifyContent: 'space-between', + margin: 0, + padding: 0, }, topGradient: { - paddingTop: Platform.OS === 'ios' ? 50 : 20, // Adjust top padding for safe area + paddingTop: 20, paddingHorizontal: 20, - paddingBottom: 10, // Add some padding at the bottom of the gradient + paddingBottom: 10, }, bottomGradient: { - paddingBottom: Platform.OS === 'ios' ? 30 : 20, + paddingBottom: 20, paddingHorizontal: 20, paddingTop: 20, }, header: { flexDirection: 'row', justifyContent: 'space-between', - alignItems: 'flex-start', // Align items to the top + alignItems: 'flex-start', }, - // Styles for the title section and metadata titleSection: { - flex: 1, // Allow title section to take available space - marginRight: 10, // Add margin to avoid overlap with close button + flex: 1, + marginRight: 10, }, title: { color: 'white', @@ -1410,7 +2221,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', marginTop: 5, - flexWrap: 'wrap', // Allow items to wrap if needed + flexWrap: 'wrap', }, metadataText: { color: 'rgba(255, 255, 255, 0.7)', @@ -1432,7 +2243,7 @@ const styles = StyleSheet.create({ providerText: { color: 'rgba(255, 255, 255, 0.7)', fontSize: 12, - fontStyle: 'italic', // Italicize provider text + fontStyle: 'italic', }, closeButton: { padding: 8, @@ -1446,7 +2257,7 @@ const styles = StyleSheet.create({ left: 0, right: 0, top: '50%', - transform: [{ translateY: -30 }], // Half the height of play button to center it perfectly + transform: [{ translateY: -30 }], zIndex: 1000, }, playButton: { @@ -1468,14 +2279,14 @@ const styles = StyleSheet.create({ }, sliderContainer: { position: 'absolute', - bottom: 55, // Moved closer to bottom buttons + bottom: 55, left: 0, right: 0, paddingHorizontal: 20, zIndex: 1000, }, progressTouchArea: { - height: 30, // Increase touch target height for easier interaction + height: 30, justifyContent: 'center', width: '100%', }, @@ -1507,8 +2318,8 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', width: '100%', paddingHorizontal: 4, - marginTop: 4, // Increased space between progress bar and time - marginBottom: 8, // Added space between time and buttons + marginTop: 4, + marginBottom: 8, }, duration: { color: 'white', @@ -1585,7 +2396,6 @@ const styles = StyleSheet.create({ textAlign: 'center', padding: 20, }, - // New simplified modal styles fullscreenOverlay: { position: 'absolute', top: 0, @@ -1674,7 +2484,6 @@ const styles = StyleSheet.create({ marginTop: 8, textAlign: 'center', }, - // Resume overlay styles resumeOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0, 0, 0, 0.7)', @@ -1812,6 +2621,8 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', zIndex: 2000, + margin: 0, + padding: 0, }, openingContent: { padding: 20, @@ -1827,8 +2638,288 @@ const styles = StyleSheet.create({ marginTop: 20, }, videoPlayerContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + margin: 0, + padding: 0, + }, + subtitleSizeContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 10, + paddingVertical: 12, + marginBottom: 8, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 6, + }, + subtitleSizeLabel: { + color: 'white', + fontSize: 14, + fontWeight: 'bold', + }, + subtitleSizeControls: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + sizeButton: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + subtitleSizeText: { + color: 'white', + fontSize: 14, + fontWeight: 'bold', + minWidth: 40, + textAlign: 'center', + }, + customSubtitleContainer: { + position: 'absolute', + bottom: 120, // Position above controls and progress bar + left: 20, + right: 20, + alignItems: 'center', + zIndex: 1500, // Higher z-index to appear above other elements + }, + customSubtitleText: { + color: 'white', + textAlign: 'center', + textShadowColor: 'rgba(0, 0, 0, 0.9)', + textShadowOffset: { width: 2, height: 2 }, + textShadowRadius: 4, + lineHeight: undefined, // Let React Native calculate line height + fontWeight: '500', + }, + loadSubtitlesButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + marginTop: 8, + borderRadius: 6, + backgroundColor: 'rgba(229, 9, 20, 0.2)', + borderWidth: 1, + borderColor: '#E50914', + }, + loadSubtitlesText: { + color: '#E50914', + fontSize: 14, + fontWeight: 'bold', + marginLeft: 8, + }, + disabledContainer: { + opacity: 0.5, + }, + disabledText: { + color: '#666', + }, + disabledButton: { + backgroundColor: '#666', + }, + noteContainer: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + noteText: { + color: '#aaa', + fontSize: 12, + marginLeft: 5, + }, + subtitleLanguageItem: { + flexDirection: 'row', + alignItems: 'center', flex: 1, }, + flagIcon: { + width: 24, + height: 18, + marginRight: 12, + borderRadius: 2, + }, + modernModalContainer: { + width: '90%', + maxWidth: 500, + backgroundColor: '#181818', + borderRadius: 10, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.4, + shadowRadius: 10, + elevation: 8, + }, + modernModalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#333', + }, + modernModalTitle: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + }, + modernCloseButton: { + padding: 4, + }, + modernTrackListScrollContainer: { + maxHeight: 350, + }, + modernTrackListContainer: { + padding: 6, + }, + sectionContainer: { + marginBottom: 20, + }, + sectionTitle: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + marginBottom: 8, + }, + sectionDescription: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginBottom: 12, + }, + trackIconContainer: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }, + modernTrackInfoContainer: { + flex: 1, + marginLeft: 10, + }, + modernTrackPrimaryText: { + color: 'white', + fontSize: 14, + fontWeight: '500', + }, + modernTrackSecondaryText: { + color: '#aaa', + fontSize: 11, + marginTop: 2, + }, + modernSelectedIndicator: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: 'rgba(255, 255, 255, 0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + modernEmptyStateContainer: { + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + modernEmptyStateText: { + color: '#888', + fontSize: 14, + marginTop: 8, + textAlign: 'center', + }, + searchSubtitlesButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + marginTop: 8, + borderRadius: 6, + backgroundColor: 'rgba(229, 9, 20, 0.2)', + borderWidth: 1, + borderColor: '#E50914', + }, + searchButtonContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + searchSubtitlesText: { + color: '#E50914', + fontSize: 14, + fontWeight: 'bold', + marginLeft: 8, + }, + modernSubtitleSizeContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + modernSizeButton: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }, + modernTrackItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 12, + marginVertical: 4, + borderRadius: 8, + backgroundColor: '#222', + }, + modernSelectedTrackItem: { + backgroundColor: 'rgba(76, 175, 80, 0.15)', + borderWidth: 1, + borderColor: 'rgba(76, 175, 80, 0.3)', + }, + sizeDisplayContainer: { + alignItems: 'center', + flex: 1, + marginHorizontal: 20, + }, + modernSubtitleSizeText: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + }, + sizeLabel: { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginTop: 2, + }, + customSubtitleWrapper: { + backgroundColor: 'rgba(0, 0, 0, 0.7)', + padding: 10, + borderRadius: 5, + }, + debugSubtitleInfo: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + padding: 5, + borderRadius: 5, + margin: 10, + zIndex: 1000, + }, + debugText: { + color: 'white', + fontSize: 12, + }, }); export default VideoPlayer; \ No newline at end of file diff --git a/src/types/navigation.d.ts b/src/types/navigation.d.ts index 83775e9e..c01523fb 100644 --- a/src/types/navigation.d.ts +++ b/src/types/navigation.d.ts @@ -27,6 +27,7 @@ export type RootStackParamList = { url: string; lang: string; }>; + imdbId?: string; }; Catalog: { addonId?: string;