diff --git a/package-lock.json b/package-lock.json index cbedefb..60caca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "react-native-tab-view": "^4.0.10", "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", + "react-native-vlc-media-player": "^1.0.87", "react-native-web": "~0.19.13", "react-native-wheel-color-picker": "^1.3.1", "subsrt": "^1.1.1" @@ -11294,6 +11295,15 @@ "react-native": "*" } }, + "node_modules/react-native-slider": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/react-native-slider/-/react-native-slider-0.11.0.tgz", + "integrity": "sha512-jV9K87eu9uWr0uJIyrSpBLnCKvVlOySC2wynq9TFCdV9oGgjt7Niq8Q1A8R8v+5GHsuBw/s8vEj1AAkkUi+u+w==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.6" + } + }, "node_modules/react-native-svg": { "version": "15.11.2", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.11.2.tgz", @@ -11455,6 +11465,100 @@ "react-native": "*" } }, + "node_modules/react-native-vlc-media-player": { + "version": "1.0.87", + "resolved": "https://registry.npmjs.org/react-native-vlc-media-player/-/react-native-vlc-media-player-1.0.87.tgz", + "integrity": "sha512-b05fW2WXVEFoatUcEcszi49FyiBF6ca9HZNQgpJYahL79obLHXRUMejh1RMlxC511UKS+TsDIe2pMJfi8NFbaA==", + "license": "MIT", + "dependencies": { + "react-native-slider": "^0.11.0", + "react-native-vector-icons": "^9.2.0" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/react-native-vlc-media-player/node_modules/react-native-vector-icons": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-9.2.0.tgz", + "integrity": "sha512-wKYLaFuQST/chH3AJRjmOLoLy3JEs1JR6zMNgTaemFpNoXs0ztRnTxcxFD9xhX7cJe1/zoN5BpQYe7kL0m5yyA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2", + "yargs": "^16.1.1" + }, + "bin": { + "fa5-upgrade": "bin/fa5-upgrade.sh", + "generate-icon": "bin/generate-icon.js" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native-vlc-media-player/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/react-native-web": { "version": "0.19.13", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz", diff --git a/package.json b/package.json index a9a4d86..7d70e09 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "expo-system-ui": "^4.0.9", "expo-web-browser": "~14.0.2", "lodash": "^4.17.21", + "node-fetch": "^2.6.7", "react": "18.3.1", "react-native": "0.76.9", "react-native-awesome-slider": "^2.9.0", @@ -61,10 +62,10 @@ "react-native-tab-view": "^4.0.10", "react-native-url-polyfill": "^2.0.0", "react-native-video": "^6.12.0", + "react-native-vlc-media-player": "^1.0.87", "react-native-web": "~0.19.13", "react-native-wheel-color-picker": "^1.3.1", - "subsrt": "^1.1.1", - "node-fetch": "^2.6.7" + "subsrt": "^1.1.1" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/src/screens/VideoPlayer.tsx b/src/screens/VideoPlayer.tsx index 7aa4b47..caf4673 100644 --- a/src/screens/VideoPlayer.tsx +++ b/src/screens/VideoPlayer.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { View, TouchableOpacity, StyleSheet, Text, Dimensions, Modal, Pressable, StatusBar, Platform, ScrollView, Animated } from 'react-native'; -import Video from 'react-native-video'; +import { VLCPlayer } from 'react-native-vlc-media-player'; import { Ionicons } from '@expo/vector-icons'; import { Slider } from 'react-native-awesome-slider'; import { LinearGradient } from 'expo-linear-gradient'; @@ -69,9 +69,9 @@ interface TextTrack { type?: string | null; // Adjusting type based on linter error } -// Define the possible resize modes -type ResizeModeType = 'contain' | 'cover' | 'stretch' | 'none'; -const resizeModes: ResizeModeType[] = ['contain', 'cover', 'stretch']; +// Define the possible resize modes - adjust to match VLCPlayer's PlayerResizeMode options +type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none'; +const resizeModes: ResizeModeType[] = ['contain', 'cover', 'fill']; // Add language code to name mapping const languageMap: {[key: string]: string} = { @@ -132,6 +132,18 @@ const formatLanguage = (code?: string): string => { return languageMap[normalized] || code.toUpperCase(); }; +// Add VLC specific interface for their event structure +interface VlcMediaEvent { + currentTime: number; + duration: number; + bufferTime?: number; + isBuffering?: boolean; + audioTracks?: Array<{id: number, name: string, language?: string}>; + textTracks?: Array<{id: number, name: string, language?: string}>; + selectedAudioTrack?: number; + selectedTextTrack?: number; +} + const VideoPlayer: React.FC = () => { const navigation = useNavigation(); const route = useRoute>(); @@ -176,7 +188,8 @@ const VideoPlayer: React.FC = () => { const [textTracks, setTextTracks] = useState([]); const [selectedTextTrack, setSelectedTextTrack] = useState({ type: 'disabled' }); const [resizeMode, setResizeMode] = useState('contain'); // State for resize mode - const videoRef = useRef(null); + const [buffered, setBuffered] = useState(0); // Add buffered state + const vlcRef = useRef(null); const progress = useSharedValue(0); const min = useSharedValue(0); const max = useSharedValue(duration); @@ -199,8 +212,12 @@ const VideoPlayer: React.FC = () => { // Add animated value for controls opacity const fadeAnim = useRef(new Animated.Value(1)).current; - // Add buffered state - const [buffered, setBuffered] = useState(0); + // Add VLC specific state and refs + const [isBuffering, setIsBuffering] = useState(false); + + // Modify audio tracks handling for VLC + const [vlcAudioTracks, setVlcAudioTracks] = useState>([]); + const [vlcTextTracks, setVlcTextTracks] = useState>([]); // Lock screen to landscape when component mounts useEffect(() => { @@ -344,22 +361,29 @@ const VideoPlayer: React.FC = () => { }; const onSliderValueChange = (value: number) => { - if (videoRef.current) { + if (vlcRef.current) { const newTime = Math.floor(value); - videoRef.current.seek(newTime); + vlcRef.current.seek(newTime); setCurrentTime(newTime); progress.value = newTime; } }; const togglePlayback = () => { - setPaused(!paused); + if (vlcRef.current) { + if (paused) { + vlcRef.current.resume(); + } else { + vlcRef.current.pause(); + } + setPaused(!paused); + } }; const skip = (seconds: number) => { - if (videoRef.current) { + if (vlcRef.current) { const newTime = Math.max(0, Math.min(currentTime + seconds, duration)); - videoRef.current.seek(newTime); + vlcRef.current.seek(newTime); setCurrentTime(newTime); progress.value = newTime; } @@ -370,21 +394,21 @@ const VideoPlayer: React.FC = () => { progress.value = data.currentTime; }; - const onLoad = (data: { duration: number }) => { - setDuration(data.duration); - max.value = data.duration; + const onLoad = (data: any) => { + setDuration(data.duration / 1000); // VLC returns duration in milliseconds + max.value = data.duration / 1000; - logger.log(`[VideoPlayer] Video loaded with duration: ${data.duration}`); + logger.log(`[VideoPlayer] Video loaded with duration: ${data.duration / 1000}`); // If we have an initial position to seek to, do it now - if (initialPosition !== null && !isInitialSeekComplete && videoRef.current) { + if (initialPosition !== null && !isInitialSeekComplete && vlcRef.current) { logger.log(`[VideoPlayer] Will seek to saved position: ${initialPosition}`); // Seek immediately with a small delay setTimeout(() => { - if (videoRef.current) { + if (vlcRef.current) { try { - videoRef.current.seek(initialPosition); + vlcRef.current.seek(initialPosition); setCurrentTime(initialPosition); progress.value = initialPosition; setIsInitialSeekComplete(true); @@ -393,7 +417,7 @@ const VideoPlayer: React.FC = () => { logger.error('[VideoPlayer] Error seeking to saved position:', error); } } else { - logger.error('[VideoPlayer] videoRef is no longer valid when attempting to seek'); + logger.error('[VideoPlayer] vlcRef is no longer valid when attempting to seek'); } }, 1000); // Increase delay to ensure video is fully loaded } else { @@ -402,7 +426,7 @@ const VideoPlayer: React.FC = () => { } else if (isInitialSeekComplete) { logger.log(`[VideoPlayer] Initial seek already completed`); } else { - logger.log(`[VideoPlayer] videoRef not available for seeking`); + logger.log(`[VideoPlayer] vlcRef not available for seeking`); } } }; @@ -484,12 +508,12 @@ const VideoPlayer: React.FC = () => { } }, [showSubtitleModal, textTracks]); - // Attempt to seek once videoRef is available + // Attempt to seek once vlcRef is available useEffect(() => { - if (initialPosition !== null && !isInitialSeekComplete && videoRef.current) { - logger.log(`[VideoPlayer] videoRef is now available, attempting to seek to: ${initialPosition}`); + if (initialPosition !== null && !isInitialSeekComplete && vlcRef.current) { + logger.log(`[VideoPlayer] vlcRef is now available, attempting to seek to: ${initialPosition}`); try { - videoRef.current.seek(initialPosition); + vlcRef.current.seek(initialPosition); setCurrentTime(initialPosition); progress.value = initialPosition; setIsInitialSeekComplete(true); @@ -498,7 +522,7 @@ const VideoPlayer: React.FC = () => { logger.error('[VideoPlayer] Error seeking to position on ref available:', error); } } - }, [videoRef.current, initialPosition, isInitialSeekComplete]); + }, [vlcRef.current, initialPosition, isInitialSeekComplete]); // Load resume preference on mount useEffect(() => { @@ -539,9 +563,9 @@ const VideoPlayer: React.FC = () => { } }; - // Handle resume from overlay - modified to save preference + // Handle resume from overlay - modified for VLC const handleResume = async () => { - if (resumePosition !== null && videoRef.current) { + if (resumePosition !== null && vlcRef.current) { logger.log(`[VideoPlayer] Resuming from ${resumePosition}`); // Save preference if remember choice is checked @@ -558,10 +582,17 @@ const VideoPlayer: React.FC = () => { setInitialPosition(resumePosition); // Hide overlay setShowResumeOverlay(false); + + // Seek to position with VLC + setTimeout(() => { + if (vlcRef.current) { + vlcRef.current.seek(resumePosition); + } + }, 500); } }; - // Handle start from beginning - modified to save preference + // Handle start from beginning - modified for VLC const handleStartFromBeginning = async () => { logger.log(`[VideoPlayer] Starting from beginning`); @@ -580,8 +611,8 @@ const VideoPlayer: React.FC = () => { // Set initial position to 0 setInitialPosition(0); // Make sure we seek to beginning - if (videoRef.current) { - videoRef.current.seek(0); + if (vlcRef.current) { + vlcRef.current.seek(0); setCurrentTime(0); progress.value = 0; } @@ -600,45 +631,269 @@ const VideoPlayer: React.FC = () => { setShowControls(!showControls); }; - // Add onBuffer handler to Video component - const onBuffer = ({ isBuffering }: { isBuffering: boolean }) => { - // You can use this to show a loading indicator if needed - logger.log(`[VideoPlayer] Buffering: ${isBuffering}`); - }; - - // Add onProgress handler to track buffered data - const onLoadStart = () => { - setBuffered(0); - }; - - const handleProgress = (data: { currentTime: number, playableDuration: number, seekableDuration?: number }) => { - setCurrentTime(data.currentTime); - progress.value = data.currentTime; + // Handle VLC progress updates + const handleProgress = (event: any) => { + const currentTimeInSeconds = event.currentTime / 1000; // VLC gives time in milliseconds + setCurrentTime(currentTimeInSeconds); + progress.value = currentTimeInSeconds; + + // Update buffered position + const bufferedTime = event.bufferTime / 1000 || currentTimeInSeconds; + setBuffered(bufferedTime); - // Ensure playableDuration is always at least equal to currentTime - const effectivePlayableDuration = Math.max(data.currentTime, data.playableDuration); - setBuffered(effectivePlayableDuration); - // Calculate buffer ahead (cannot be negative) - const bufferAhead = Math.max(0, effectivePlayableDuration - data.currentTime); - const bufferPercentage = ((effectivePlayableDuration / (duration || 1)) * 100); + const bufferAhead = Math.max(0, bufferedTime - currentTimeInSeconds); + const bufferPercentage = ((bufferedTime / (duration || 1)) * 100); // Add detailed buffer logging logger.log(`[VideoPlayer] Buffer Status: - Current Time: ${data.currentTime.toFixed(2)}s - Playable Duration: ${effectivePlayableDuration.toFixed(2)}s + Current Time: ${currentTimeInSeconds.toFixed(2)}s + Buffered: ${bufferedTime.toFixed(2)}s Buffered Ahead: ${bufferAhead.toFixed(2)}s - Seekable Duration: ${data.seekableDuration?.toFixed(2) || 'N/A'}s Buffer Percentage: ${bufferPercentage.toFixed(1)}% `); }; - // Add onError handler + // Handle VLC errors const handleError = (error: any) => { logger.error('[VideoPlayer] Playback Error:', error); // Optionally, you could show an error message to the user here }; + // Handle VLC buffering + const onBuffering = (event: any) => { + setIsBuffering(event.isBuffering); + logger.log(`[VideoPlayer] Buffering: ${event.isBuffering}`); + }; + + // Handle VLC playback ended + const onEnd = () => { + // Your existing playback ended logic here + }; + + // Function to get audio tracks from VLC + const getAudioTracks = () => { + if (vlcRef.current) { + vlcRef.current.getAudioTracks().then((tracks: any) => { + setVlcAudioTracks(tracks || []); + logger.log("[VideoPlayer] Available VLC audio tracks:", tracks); + }).catch((error: any) => { + logger.error("[VideoPlayer] Failed to get audio tracks:", error); + }); + } + }; + + // Function to select audio track in VLC + const selectAudioTrack = (trackId: number) => { + if (vlcRef.current) { + vlcRef.current.setAudioTrack(trackId); + setSelectedAudioTrack(trackId); + } + }; + + // Function to get subtitle tracks from VLC + const getTextTracks = () => { + if (vlcRef.current) { + vlcRef.current.getTextTracks().then((tracks: any) => { + setVlcTextTracks(tracks || []); + logger.log("[VideoPlayer] Available VLC subtitle tracks:", tracks); + }).catch((error: any) => { + logger.error("[VideoPlayer] Failed to get subtitle tracks:", error); + }); + } + }; + + // Function to select subtitle track in VLC + const selectTextTrack = (trackId: number) => { + if (vlcRef.current) { + vlcRef.current.setTextTrack(trackId); + // Update your state accordingly + setSelectedTextTrack({ type: 'index', value: trackId }); + } + }; + + // Add this useEffect to get audio and subtitle tracks after player is loaded + useEffect(() => { + if (duration > 0 && vlcRef.current) { + // Wait a bit for VLC to fully initialize and recognize tracks + setTimeout(() => { + getAudioTracks(); + getTextTracks(); + }, 2000); + } + }, [duration]); + + // Update audio modal to use VLC audio tracks + const renderAudioModal = () => { + if (!showAudioModal) return null; + + return ( + + + + Audio + setShowAudioModal(false)} + > + + + + + + + {vlcAudioTracks.length > 0 ? vlcAudioTracks.map(track => ( + { + selectAudioTrack(track.id); + setShowAudioModal(false); + }} + > + + + {formatLanguage(track.language) || track.name || `Track ${track.id}`} + + {(track.name && track.language) && ( + {track.name} + )} + + {selectedAudioTrack === track.id && ( + + + + )} + + )) : ( + + + No audio tracks available + + )} + + + + + ); + }; + + // Update subtitle modal to use VLC subtitle tracks + const renderSubtitleModal = () => { + if (!showSubtitleModal) return null; + + return ( + + + + Subtitles + setShowSubtitleModal(false)} + > + + + + + + + {/* Off option with improved design */} + { + selectTextTrack(-1); // -1 typically disables subtitles in VLC + setShowSubtitleModal(false); + }} + > + + Off + + {(selectedTextTrack?.type === 'disabled' || + (selectedTextTrack?.type === 'index' && selectedTextTrack.value === -1)) && ( + + + + )} + + + {/* Available subtitle tracks with improved design */} + {vlcTextTracks.length > 0 ? vlcTextTracks.map(track => ( + { + selectTextTrack(track.id); + setShowSubtitleModal(false); + }} + > + + + {formatLanguage(track.language) || track.name || `Subtitle ${track.id}`} + + {(track.name && track.language) && ( + {track.name} + )} + + {selectedTextTrack?.type === 'index' && + selectedTextTrack?.value === track.id && ( + + + + )} + + )) : ( + + + No subtitle tracks available + + )} + + + + + ); + }; + + // Update the getInfo method for VLC + const getInfo = async () => { + if (vlcRef.current) { + try { + const position = await vlcRef.current.getPosition(); + const lengthResult = await vlcRef.current.getLength(); + return { + currentTime: position, + duration: lengthResult / 1000 // Convert to seconds + }; + } catch (e) { + logger.error('[VideoPlayer] Error getting playback info:', e); + return { + currentTime: currentTime, + duration: duration + }; + } + } + return { + currentTime: 0, + duration: 0 + }; + }; + + // VLC specific method to set playback speed + const changePlaybackSpeed = (speed: number) => { + if (vlcRef.current) { + vlcRef.current.setRate(speed); + setPlaybackSpeed(speed); + } + }; + + // VLC specific method for volume control + const setVolume = (volumeLevel: number) => { + if (vlcRef.current) { + // VLC volume is typically between 0-200 + vlcRef.current.setVolume(volumeLevel * 200); + } + }; + return ( { onPress={toggleControls} activeOpacity={1} > - - {/* Audio Selection Modal - Updated language display */} - {showAudioModal && ( - - - - Audio - setShowAudioModal(false)} - > - - - - - - - {audioTracks.length > 0 ? audioTracks.map(track => ( - { - setSelectedAudioTrack(track.index); - setShowAudioModal(false); - }} - > - - - {formatLanguage(track.language) || track.title || `Track ${track.index + 1}`} - - {(track.title && track.language) && ( - {track.title} - )} - {track.type && {track.type}} - - {selectedAudioTrack === track.index && ( - - - - )} - - )) : ( - - - No audio tracks available - - )} - - - - - )} - - {/* Subtitle Selection Modal - Updated language display */} - {showSubtitleModal && ( - - - - Subtitles - setShowSubtitleModal(false)} - > - - - - - - - {/* Off option with improved design */} - { - setSelectedTextTrack({ type: 'disabled' }); - setShowSubtitleModal(false); - }} - > - - Off - - {selectedTextTrack?.type === 'disabled' && ( - - - - )} - - - {/* Available subtitle tracks with improved design */} - {textTracks.length > 0 ? textTracks.map(track => ( - { - setSelectedTextTrack({ type: 'index', value: track.index }); - setShowSubtitleModal(false); - }} - > - - - {formatLanguage(track.language) || track.title || `Subtitle ${track.index + 1}`} - - {(track.title && track.language) && ( - {track.title} - )} - {track.type && {track.type}} - - {selectedTextTrack?.type === 'index' && - selectedTextTrack?.value === track.index && ( - - - - )} - - )) : ( - - - No subtitle tracks available - - )} - - - - - )} + {/* Use the new modal rendering functions */} + {renderAudioModal()} + {renderSubtitleModal()} ); }; diff --git a/tsconfig.json b/tsconfig.json index b9567f6..ff3c18d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "expo/tsconfig.base", "compilerOptions": { - "strict": true + "strict": true, + "jsx": "react-jsx", + "esModuleInterop": true } }