diff --git a/externals/ffmpeg b/externals/ffmpeg new file mode 160000 index 00000000..7334356a --- /dev/null +++ b/externals/ffmpeg @@ -0,0 +1 @@ +Subproject commit 7334356a3e9768eb519206151eec0ffac377f110 diff --git a/src/components/CustomAlert.tsx b/src/components/CustomAlert.tsx index 7540ed51..0c72bdd7 100644 --- a/src/components/CustomAlert.tsx +++ b/src/components/CustomAlert.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { Modal, View, @@ -44,7 +44,7 @@ export const CustomAlert = ({ const themeColors = currentTheme.colors; useEffect(() => { - const animDuration = 120; + const animDuration = Platform.OS === 'android' ? 200 : 120; if (visible) { opacity.value = withTiming(1, { duration: animDuration }); scale.value = withTiming(1, { duration: animDuration }); @@ -67,14 +67,69 @@ export const CustomAlert = ({ const textColor = isDarkMode ? themeColors.white : themeColors.black || '#000000'; const borderColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'; + // Safe action handler to prevent crashes + const handleActionPress = useCallback((action: { label: string; onPress: () => void; style?: object }) => { + try { + action.onPress(); + onClose(); + } catch (error) { + console.warn('[CustomAlert] Error in action handler:', error); + // Still close the alert even if action fails + onClose(); + } + }, [onClose]); + + // Don't render anything if not visible + if (!visible) { + return null; + } + + // Use different rendering approach for Android to avoid Modal issues + if (Platform.OS === 'android') { + return ( + + + + + + {title} + {message} + + {actions.map((action, idx) => ( + handleActionPress(action)} + activeOpacity={0.7} + > + {action.label} + + ))} + + + + + + ); + } + + // iOS version with animations return ( - + @@ -85,10 +140,8 @@ export const CustomAlert = ({ { - action.onPress(); - onClose(); - }} + onPress={() => handleActionPress(action)} + activeOpacity={0.7} > {action.label} diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 02184106..494b2302 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -93,7 +93,6 @@ const AndroidVideoPlayer: React.FC = () => { const reason = useVLC ? (TEMP_FORCE_VLC ? 'TEMP_FORCE_VLC=true' : `forceVlc=${forceVlc} from route params`) : 'default react-native-video'; - console.log(`🎬 [AndroidVideoPlayer] Using ${playerType} - ${reason}`); logger.log(`[AndroidVideoPlayer] Player selection: ${playerType} (${reason})`); }, [useVLC, forceVlc]); @@ -237,6 +236,9 @@ const AndroidVideoPlayer: React.FC = () => { // Debounce resize operations to prevent rapid successive clicks const resizeTimeoutRef = useRef(null); + // Debounce gesture operations to prevent rapid-fire events + const gestureDebounceRef = useRef(null); + // Memoize VLC tracks prop to prevent unnecessary re-renders const vlcTracks = useMemo(() => ({ audio: vlcSelectedAudioTrack, @@ -244,20 +246,73 @@ const AndroidVideoPlayer: React.FC = () => { subtitle: vlcSelectedSubtitleTrack }), [vlcSelectedAudioTrack, vlcSelectedSubtitleTrack]); - // Format VLC tracks to match RN Video format - optimized version + // Format VLC tracks to match RN Video format - raw version const formatVlcTracks = useCallback((vlcTracks: Array<{id: number, name: string}>) => { if (!Array.isArray(vlcTracks)) return []; - return vlcTracks.map(track => ({ - id: track.id, - name: track.name || `Track ${track.id + 1}`, - language: undefined // VLC doesn't provide language info in this format - })); + return vlcTracks.map(track => { + // Just extract basic language info if available, but keep the full name + let language = undefined; + let displayName = track.name || `Track ${track.id + 1}`; + + // Log the raw track data for debugging + if (DEBUG_MODE) { + logger.log(`[VLC] Raw track data:`, { id: track.id, name: track.name }); + } + + // Only extract language from brackets if present, but keep full name + const languageMatch = track.name?.match(/\[([^\]]+)\]/); + if (languageMatch && languageMatch[1]) { + language = languageMatch[1].trim(); + } + + return { + id: track.id, + name: displayName, // Show exactly what VLC provides + language: language + }; + }); }, []); - // Optimized VLC track processing function + // Process URL for VLC compatibility + const processUrlForVLC = useCallback((url: string): string => { + if (!url || typeof url !== 'string') { + logger.warn('[AndroidVideoPlayer][VLC] Invalid URL provided:', url); + return url || ''; + } + + try { + // Check if URL is already properly formatted + const urlObj = new URL(url); + + // Handle special characters in the pathname that might cause issues + const pathname = urlObj.pathname; + const search = urlObj.search; + const hash = urlObj.hash; + + // Decode and re-encode the pathname to handle double-encoding + const decodedPathname = decodeURIComponent(pathname); + const encodedPathname = encodeURI(decodedPathname); + + // Reconstruct the URL + const processedUrl = `${urlObj.protocol}//${urlObj.host}${encodedPathname}${search}${hash}`; + + logger.log(`[AndroidVideoPlayer][VLC] URL processed: ${url} -> ${processedUrl}`); + return processedUrl; + } catch (error) { + logger.warn(`[AndroidVideoPlayer][VLC] URL processing failed, using original: ${error}`); + return url; + } + }, []); + + // Optimized VLC track processing function with reduced JSON operations const processVlcTracks = useCallback((tracks: any, source: string) => { if (!tracks) return; + // Log raw VLC tracks data for debugging + if (DEBUG_MODE) { + logger.log(`[VLC] ${source} - Raw tracks data:`, tracks); + } + // Clear any pending updates if (trackUpdateTimeoutRef.current) { clearTimeout(trackUpdateTimeoutRef.current); @@ -268,29 +323,39 @@ const AndroidVideoPlayer: React.FC = () => { const { audio = [], subtitle = [] } = tracks; let hasUpdates = false; - // Process audio tracks + // Process audio tracks with optimized comparison if (Array.isArray(audio) && audio.length > 0) { const formattedAudio = formatVlcTracks(audio); - if (formattedAudio.length !== vlcAudioTracks.length || - JSON.stringify(formattedAudio) !== JSON.stringify(vlcAudioTracks)) { + // Use length and first/last item comparison instead of full JSON.stringify + const audioChanged = formattedAudio.length !== vlcAudioTracks.length || + (formattedAudio.length > 0 && vlcAudioTracks.length > 0 && + (formattedAudio[0]?.id !== vlcAudioTracks[0]?.id || + formattedAudio[formattedAudio.length - 1]?.id !== vlcAudioTracks[vlcAudioTracks.length - 1]?.id)); + + if (audioChanged) { setVlcAudioTracks(formattedAudio); hasUpdates = true; // Only log in debug mode or when tracks actually change if (DEBUG_MODE) { - console.log(`🎬 [VLC] ${source} - Audio tracks updated:`, formattedAudio.length); + logger.log(`[VLC] ${source} - Audio tracks updated:`, formattedAudio.length); } } } - // Process subtitle tracks + // Process subtitle tracks with optimized comparison if (Array.isArray(subtitle) && subtitle.length > 0) { const formattedSubs = formatVlcTracks(subtitle); - if (formattedSubs.length !== vlcSubtitleTracks.length || - JSON.stringify(formattedSubs) !== JSON.stringify(vlcSubtitleTracks)) { + // Use length and first/last item comparison instead of full JSON.stringify + const subsChanged = formattedSubs.length !== vlcSubtitleTracks.length || + (formattedSubs.length > 0 && vlcSubtitleTracks.length > 0 && + (formattedSubs[0]?.id !== vlcSubtitleTracks[0]?.id || + formattedSubs[formattedSubs.length - 1]?.id !== vlcSubtitleTracks[vlcSubtitleTracks.length - 1]?.id)); + + if (subsChanged) { setVlcSubtitleTracks(formattedSubs); hasUpdates = true; if (DEBUG_MODE) { - console.log(`🎬 [VLC] ${source} - Subtitle tracks updated:`, formattedSubs.length); + logger.log(`[VLC] ${source} - Subtitle tracks updated:`, formattedSubs.length); } } } @@ -338,9 +403,15 @@ const AndroidVideoPlayer: React.FC = () => { return () => { if (trackUpdateTimeoutRef.current) { clearTimeout(trackUpdateTimeoutRef.current); + trackUpdateTimeoutRef.current = null; } if (resizeTimeoutRef.current) { clearTimeout(resizeTimeoutRef.current); + resizeTimeoutRef.current = null; + } + if (gestureDebounceRef.current) { + clearTimeout(gestureDebounceRef.current); + gestureDebounceRef.current = null; } }; }, []); @@ -355,13 +426,11 @@ const AndroidVideoPlayer: React.FC = () => { // VLC track selection handlers const selectVlcAudioTrack = useCallback((trackId: number | null) => { setVlcSelectedAudioTrack(trackId ?? undefined); - console.log('🎬 [VLC] Audio track selected:', trackId); logger.log('[AndroidVideoPlayer][VLC] Audio track selected:', trackId); }, []); const selectVlcSubtitleTrack = useCallback((trackId: number | null) => { setVlcSelectedSubtitleTrack(trackId ?? undefined); - console.log('🎬 [VLC] Subtitle track selected:', trackId); logger.log('[AndroidVideoPlayer][VLC] Subtitle track selected:', trackId); }, []); @@ -446,6 +515,11 @@ const AndroidVideoPlayer: React.FC = () => { const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {}); const [currentStreamUrl, setCurrentStreamUrl] = useState(uri); const [currentVideoType, setCurrentVideoType] = useState(videoType); + + // Memoized processed URL for VLC to prevent infinite loops + const processedStreamUrl = useMemo(() => { + return useVLC ? processUrlForVLC(currentStreamUrl) : currentStreamUrl; + }, [currentStreamUrl, useVLC, processUrlForVLC]); // Track a single silent retry per source to avoid loops const retryAttemptRef = useRef(0); const [isChangingSource, setIsChangingSource] = useState(false); @@ -459,6 +533,7 @@ const AndroidVideoPlayer: React.FC = () => { const [showErrorModal, setShowErrorModal] = useState(false); const [errorDetails, setErrorDetails] = useState(''); const errorTimeoutRef = useRef(null); + const vlcFallbackAttemptedRef = useRef(false); // VLC refs/state const vlcRef = useRef(null); @@ -676,105 +751,121 @@ const AndroidVideoPlayer: React.FC = () => { } }; - // Volume gesture handler (right side of screen) + // Volume gesture handler (right side of screen) - optimized with debouncing const onVolumeGestureEvent = async (event: PanGestureHandlerGestureEvent) => { const { translationY, state } = event.nativeEvent; const sensitivity = 0.002; // Lower sensitivity for gradual volume control on Android if (state === State.ACTIVE) { - const deltaY = -translationY; // Invert for natural feel (up = increase) - const volumeChange = deltaY * sensitivity; - const newVolume = Math.max(0, Math.min(1, volume + volumeChange)); - - if (Math.abs(newVolume - volume) > 0.01) { // Lower threshold for smoother Android volume control - setVolume(newVolume); - lastVolumeChange.current = Date.now(); - - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Volume set to: ${newVolume}`); - } - - // Show overlay with smoother animation - if (!showVolumeOverlay) { - setShowVolumeOverlay(true); - Animated.spring(volumeOverlayOpacity, { - toValue: 1, - tension: 100, - friction: 8, - useNativeDriver: true, - }).start(); - } - - // Clear existing timeout - if (volumeOverlayTimeout.current) { - clearTimeout(volumeOverlayTimeout.current); - } - - // Hide overlay after 1.5 seconds (reduced from 2 seconds) - volumeOverlayTimeout.current = setTimeout(() => { - Animated.timing(volumeOverlayOpacity, { - toValue: 0, - duration: 250, - useNativeDriver: true, - }).start(() => { - setShowVolumeOverlay(false); - }); - }, 1500); + // Debounce rapid gesture events + if (gestureDebounceRef.current) { + clearTimeout(gestureDebounceRef.current); } + + gestureDebounceRef.current = setTimeout(() => { + const deltaY = -translationY; // Invert for natural feel (up = increase) + const volumeChange = deltaY * sensitivity; + const newVolume = Math.max(0, Math.min(1, volume + volumeChange)); + + if (Math.abs(newVolume - volume) > 0.01) { // Lower threshold for smoother Android volume control + setVolume(newVolume); + lastVolumeChange.current = Date.now(); + + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Volume set to: ${newVolume}`); + } + + // Show overlay with smoother animation + if (!showVolumeOverlay) { + setShowVolumeOverlay(true); + Animated.spring(volumeOverlayOpacity, { + toValue: 1, + tension: 100, + friction: 8, + useNativeDriver: true, + }).start(); + } + + // Clear existing timeout + if (volumeOverlayTimeout.current) { + clearTimeout(volumeOverlayTimeout.current); + } + + // Hide overlay after 1.5 seconds (reduced from 2 seconds) + volumeOverlayTimeout.current = setTimeout(() => { + Animated.timing(volumeOverlayOpacity, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }).start(() => { + setShowVolumeOverlay(false); + }); + }, 1500); + } + }, 16); // ~60fps debouncing } }; - // Brightness gesture handler (left side of screen) + // Brightness gesture handler (left side of screen) - optimized with debouncing const onBrightnessGestureEvent = async (event: PanGestureHandlerGestureEvent) => { const { translationY, state } = event.nativeEvent; const sensitivity = 0.001; // Lower sensitivity for finer brightness control if (state === State.ACTIVE) { - const deltaY = -translationY; // Invert for natural feel (up = increase) - const brightnessChange = deltaY * sensitivity; - const newBrightness = Math.max(0, Math.min(1, brightness + brightnessChange)); - - if (Math.abs(newBrightness - brightness) > 0.001) { // Much lower threshold for more responsive updates - setBrightness(newBrightness); - lastBrightnessChange.current = Date.now(); - - // Set device brightness using Expo Brightness - try { - await Brightness.setBrightnessAsync(newBrightness); - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Device brightness set to: ${newBrightness}`); - } - } catch (error) { - logger.warn('[AndroidVideoPlayer] Error setting device brightness:', error); - } - - // Show overlay with smoother animation - if (!showBrightnessOverlay) { - setShowBrightnessOverlay(true); - Animated.spring(brightnessOverlayOpacity, { - toValue: 1, - tension: 100, - friction: 8, - useNativeDriver: true, - }).start(); - } - - // Clear existing timeout - if (brightnessOverlayTimeout.current) { - clearTimeout(brightnessOverlayTimeout.current); - } - - // Hide overlay after 1.5 seconds (reduced from 2 seconds) - brightnessOverlayTimeout.current = setTimeout(() => { - Animated.timing(brightnessOverlayOpacity, { - toValue: 0, - duration: 250, - useNativeDriver: true, - }).start(() => { - setShowBrightnessOverlay(false); - }); - }, 1500); + // Debounce rapid gesture events + if (gestureDebounceRef.current) { + clearTimeout(gestureDebounceRef.current); } + + gestureDebounceRef.current = setTimeout(() => { + const deltaY = -translationY; // Invert for natural feel (up = increase) + const brightnessChange = deltaY * sensitivity; + const newBrightness = Math.max(0, Math.min(1, brightness + brightnessChange)); + + if (Math.abs(newBrightness - brightness) > 0.001) { // Much lower threshold for more responsive updates + setBrightness(newBrightness); + lastBrightnessChange.current = Date.now(); + + // Set device brightness using Expo Brightness + try { + Brightness.setBrightnessAsync(newBrightness).catch((error) => { + logger.warn('[AndroidVideoPlayer] Error setting device brightness:', error); + }); + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Device brightness set to: ${newBrightness}`); + } + } catch (error) { + logger.warn('[AndroidVideoPlayer] Error setting device brightness:', error); + } + + // Show overlay with smoother animation + if (!showBrightnessOverlay) { + setShowBrightnessOverlay(true); + Animated.spring(brightnessOverlayOpacity, { + toValue: 1, + tension: 100, + friction: 8, + useNativeDriver: true, + }).start(); + } + + // Clear existing timeout + if (brightnessOverlayTimeout.current) { + clearTimeout(brightnessOverlayTimeout.current); + } + + // Hide overlay after 1.5 seconds (reduced from 2 seconds) + brightnessOverlayTimeout.current = setTimeout(() => { + Animated.timing(brightnessOverlayOpacity, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }).start(() => { + setShowBrightnessOverlay(false); + }); + }, 1500); + } + }, 16); // ~60fps debouncing } }; @@ -840,7 +931,7 @@ const AndroidVideoPlayer: React.FC = () => { enableImmersiveMode(); // Workaround for VLC surface detach: force complete remount VLC view on focus if (useVLC) { - console.log('🎬 [VLC] Forcing complete remount due to focus gain'); + logger.log('[VLC] Forcing complete remount due to focus gain'); setVlcRestoreTime(currentTime); // Save current time for restoration setForceVlcRemount(true); // Re-enable after a brief moment @@ -860,7 +951,7 @@ const AndroidVideoPlayer: React.FC = () => { enableImmersiveMode(); if (useVLC) { // Force complete remount VLC view when app returns to foreground - console.log('🎬 [VLC] Forcing complete remount due to app foreground'); + logger.log('[VLC] Forcing complete remount due to app foreground'); setVlcRestoreTime(currentTime); // Save current time for restoration setForceVlcRemount(true); // Re-enable after a brief moment @@ -1047,8 +1138,8 @@ const AndroidVideoPlayer: React.FC = () => { clearInterval(progressSaveInterval); } - // Sync interval for progress updates - const syncInterval = 5000; // 5 seconds for responsive progress tracking + // Sync interval for progress updates - increased from 5s to 10s to reduce overhead + const syncInterval = 10000; // 10 seconds for better performance const interval = setInterval(() => { saveWatchProgress(); @@ -1218,8 +1309,8 @@ const AndroidVideoPlayer: React.FC = () => { const currentTimeInSeconds = data.currentTime; - // Update time more frequently for subtitle synchronization (0.1s threshold) - if (Math.abs(currentTimeInSeconds - currentTime) > 0.1) { + // Update time less frequently for better performance (increased threshold from 0.1s to 0.5s) + if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { safeSetState(() => setCurrentTime(currentTimeInSeconds)); // Removed progressAnim animation - no longer needed with React Native Community Slider const bufferedTime = data.playableDuration || currentTimeInSeconds; @@ -1409,15 +1500,100 @@ const AndroidVideoPlayer: React.FC = () => { } } - // Handle text tracks - if (data.textTracks && data.textTracks.length > 0) { - const formattedTextTracks = data.textTracks.map((track: any, index: number) => ({ - id: track.index || index, - name: track.title || track.language || `Subtitle ${index + 1}`, - language: track.language, - })); - setRnVideoTextTracks(formattedTextTracks); - } + // Handle text tracks + if (data.textTracks && data.textTracks.length > 0) { + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Raw text tracks data:`, data.textTracks); + data.textTracks.forEach((track: any, idx: number) => { + logger.log(`[AndroidVideoPlayer] Text Track ${idx} raw data:`, { + index: track.index, + title: track.title, + language: track.language, + type: track.type, + name: track.name, + label: track.label, + allKeys: Object.keys(track), + fullTrackObject: track + }); + }); + } + + const formattedTextTracks = data.textTracks.map((track: any, index: number) => { + const trackIndex = track.index !== undefined ? track.index : index; + + // Build comprehensive track name from available fields + let trackName = ''; + const parts = []; + + // Add language if available (try multiple possible fields) + let language = track.language || track.lang || track.languageCode; + + // If no language field, try to extract from track name (e.g., "[Russian]", "[English]") + if ((!language || language === 'Unknown' || language === 'und' || language === '') && track.title) { + const languageMatch = track.title.match(/\[([^\]]+)\]/); + if (languageMatch && languageMatch[1]) { + language = languageMatch[1].trim(); + } + } + + if (language && language !== 'Unknown' && language !== 'und' && language !== '') { + parts.push(language.toUpperCase()); + } + + // Add codec information if available (try multiple possible fields) + const codec = track.codec || track.format; + if (codec && codec !== 'Unknown' && codec !== 'und') { + parts.push(codec.toUpperCase()); + } + + // Add title if available and not generic + let title = track.title || track.name || track.label; + if (title && !title.match(/^(Subtitle|Track)\s*\d*$/i) && title !== 'Unknown') { + // Clean up title by removing language brackets and trailing punctuation + title = title.replace(/\s*\[[^\]]+\]\s*[-–—]*\s*$/, '').trim(); + if (title && title !== 'Unknown') { + parts.push(title); + } + } + + // Combine parts or fallback to generic name + if (parts.length > 0) { + trackName = parts.join(' • '); + } else { + // For simple track names like "Track 1", "Subtitle 1", etc., use them as-is + const simpleName = track.title || track.name || track.label; + if (simpleName && simpleName.match(/^(Track|Subtitle)\s*\d*$/i)) { + trackName = simpleName; + } else { + // Try to extract any meaningful info from the track object + const meaningfulFields: string[] = []; + Object.keys(track).forEach(key => { + const value = track[key]; + if (value && typeof value === 'string' && value !== 'Unknown' && value !== 'und' && value.length > 1) { + meaningfulFields.push(`${key}: ${value}`); + } + }); + + if (meaningfulFields.length > 0) { + trackName = meaningfulFields.join(' • '); + } else { + trackName = `Subtitle ${index + 1}`; + } + } + } + + return { + id: trackIndex, // Use the actual track index from react-native-video + name: trackName, + language: language, + }; + }); + setRnVideoTextTracks(formattedTextTracks); + + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Formatted text tracks:`, formattedTextTracks); + } + } setIsVideoLoaded(true); setIsPlayerReady(true); @@ -1529,7 +1705,7 @@ const AndroidVideoPlayer: React.FC = () => { NativeModules.StatusBarManager.setHidden(true); } } catch (error) { - if (__DEV__) console.log('Immersive mode error:', error); + logger.warn('[AndroidVideoPlayer] Immersive mode error:', error); } } }; @@ -1664,6 +1840,37 @@ const AndroidVideoPlayer: React.FC = () => { return; } + // Check for codec errors that should trigger VLC fallback + const errorString = JSON.stringify(error || {}); + const isCodecError = errorString.includes('MediaCodecVideoRenderer error') || + errorString.includes('MediaCodecAudioRenderer error') || + errorString.includes('NO_EXCEEDS_CAPABILITIES') || + errorString.includes('NO_UNSUPPORTED_TYPE') || + errorString.includes('Decoder failed') || + errorString.includes('video/hevc') || + errorString.includes('audio/eac3') || + errorString.includes('ERROR_CODE_DECODING_FAILED') || + errorString.includes('ERROR_CODE_DECODER_INIT_FAILED'); + + // If it's a codec error and we're not already using VLC, silently switch to VLC + if (isCodecError && !useVLC && LibVlcPlayerViewComponent && !vlcFallbackAttemptedRef.current) { + vlcFallbackAttemptedRef.current = true; + logger.warn('[AndroidVideoPlayer] Codec error detected, silently switching to VLC'); + // Clear any existing timeout + if (errorTimeoutRef.current) { + clearTimeout(errorTimeoutRef.current); + errorTimeoutRef.current = null; + } + safeSetState(() => setShowErrorModal(false)); + + // Switch to VLC silently + setTimeout(() => { + if (!isMounted.current) return; + // Force VLC by updating the route params + navigation.setParams({ forceVlc: true } as any); + }, 100); + return; // Do not proceed to show error UI + } // One-shot, silent retry without showing error UI if (retryAttemptRef.current < 1) { @@ -1713,7 +1920,8 @@ const AndroidVideoPlayer: React.FC = () => { // Force HLS type and add cache-busting setCurrentVideoType('m3u8'); const sep = currentStreamUrl.includes('?') ? '&' : '?'; - setCurrentStreamUrl(`${currentStreamUrl}${sep}hls_retry=${Date.now()}`); + const retryUrl = `${currentStreamUrl}${sep}hls_retry=${Date.now()}`; + setCurrentStreamUrl(retryUrl); setPaused(false); }, 120); return; @@ -1732,7 +1940,8 @@ const AndroidVideoPlayer: React.FC = () => { setCurrentVideoType(nextType); // Force re-mount of source by tweaking URL param const sep = currentStreamUrl.includes('?') ? '&' : '?'; - setCurrentStreamUrl(`${currentStreamUrl}${sep}rn_type_retry=${Date.now()}`); + const retryUrl = `${currentStreamUrl}${sep}rn_type_retry=${Date.now()}`; + setCurrentStreamUrl(retryUrl); setPaused(false); }, 120); return; @@ -1760,7 +1969,8 @@ const AndroidVideoPlayer: React.FC = () => { setCurrentVideoType('mp4'); // Force re-mount of source by tweaking URL param const sep = currentStreamUrl.includes('?') ? '&' : '?'; - setCurrentStreamUrl(`${currentStreamUrl}${sep}manifest_fix_retry=${Date.now()}`); + const retryUrl = `${currentStreamUrl}${sep}manifest_fix_retry=${Date.now()}`; + setCurrentStreamUrl(retryUrl); setPaused(false); }, 120); return; @@ -1879,7 +2089,7 @@ const AndroidVideoPlayer: React.FC = () => { } catch (error) { logger.warn('[AndroidVideoPlayer] Failed to maintain keep-awake:', error); } - }, 5000); // Re-activate every 5 seconds to ensure it stays active + }, 10000); // Reduced frequency from 5s to 10s to reduce overhead return () => { clearInterval(keepAliveInterval); @@ -2568,17 +2778,38 @@ const AndroidVideoPlayer: React.FC = () => { isMounted.current = true; return () => { isMounted.current = false; + // Clear all timers and intervals if (seekDebounceTimer.current) { clearTimeout(seekDebounceTimer.current); + seekDebounceTimer.current = null; } if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); + errorTimeoutRef.current = null; } if (volumeOverlayTimeout.current) { clearTimeout(volumeOverlayTimeout.current); + volumeOverlayTimeout.current = null; } if (brightnessOverlayTimeout.current) { clearTimeout(brightnessOverlayTimeout.current); + brightnessOverlayTimeout.current = null; + } + if (controlsTimeout.current) { + clearTimeout(controlsTimeout.current); + controlsTimeout.current = null; + } + if (pauseOverlayTimerRef.current) { + clearTimeout(pauseOverlayTimerRef.current); + pauseOverlayTimerRef.current = null; + } + if (gestureDebounceRef.current) { + clearTimeout(gestureDebounceRef.current); + gestureDebounceRef.current = null; + } + if (progressSaveInterval) { + clearInterval(progressSaveInterval); + setProgressSaveInterval(null); } }; }, []); @@ -3084,7 +3315,7 @@ const AndroidVideoPlayer: React.FC = () => { style={[styles.video, customVideoStyles, { transform: [{ scale: zoomScale }] }]} // Force remount when surfaces are recreated key={vlcKey} - source={currentStreamUrl} + source={processedStreamUrl} aspectRatio={vlcAspectRatio} options={vlcOptions} tracks={vlcTracks} @@ -3096,7 +3327,7 @@ const AndroidVideoPlayer: React.FC = () => { onFirstPlay={(info: any) => { try { if (DEBUG_MODE) { - console.log('🎬 [VLC] Video loaded, extracting tracks...'); + logger.log('[VLC] Video loaded, extracting tracks...'); } logger.log('[AndroidVideoPlayer][VLC] Video loaded successfully'); @@ -3113,21 +3344,21 @@ const AndroidVideoPlayer: React.FC = () => { // Restore playback position after remount (workaround for surface detach) if (vlcRestoreTime !== undefined && vlcRestoreTime > 0) { if (DEBUG_MODE) { - console.log('🎬 [VLC] Restoring playback position:', vlcRestoreTime); + logger.log('[VLC] Restoring playback position:', vlcRestoreTime); } setTimeout(() => { if (vlcRef.current && typeof vlcRef.current.seek === 'function') { const seekPosition = Math.min(vlcRestoreTime / lenSec, 0.999); // Convert to fraction vlcRef.current.seek(seekPosition); if (DEBUG_MODE) { - console.log('🎬 [VLC] Seeked to restore position'); + logger.log('[VLC] Seeked to restore position'); } } }, 500); // Small delay to ensure player is ready setVlcRestoreTime(undefined); // Clear restore time } } catch (e) { - console.error('🎬 [VLC] onFirstPlay error:', e); + logger.error('[VLC] onFirstPlay error:', e); logger.warn('[AndroidVideoPlayer][VLC] onFirstPlay parse error', e); } }} @@ -3142,17 +3373,16 @@ const AndroidVideoPlayer: React.FC = () => { onPaused={() => setPaused(true)} onEndReached={onEnd} onEncounteredError={(e: any) => { - console.log('🎬 [VLC] Encountered error:', e); logger.error('[AndroidVideoPlayer][VLC] Encountered error:', e); handleError(e); }} onBackground={() => { - console.log('🎬 [VLC] App went to background'); + logger.log('[VLC] App went to background'); }} onESAdded={(tracks: any) => { try { if (DEBUG_MODE) { - console.log('🎬 [VLC] ES Added - processing tracks...'); + logger.log('[VLC] ES Added - processing tracks...'); } // Process VLC tracks using optimized function @@ -3160,7 +3390,7 @@ const AndroidVideoPlayer: React.FC = () => { processVlcTracks(tracks, 'onESAdded'); } } catch (e) { - console.error('🎬 [VLC] onESAdded error:', e); + logger.error('[VLC] onESAdded error:', e); logger.warn('[AndroidVideoPlayer][VLC] onESAdded parse error', e); } }} @@ -3177,9 +3407,8 @@ const AndroidVideoPlayer: React.FC = () => { }} paused={paused} onLoadStart={() => { - console.log('🎬 [RN Video] Load started'); - loadStartAtRef.current = Date.now(); logger.log('[AndroidVideoPlayer][RN Video] onLoadStart'); + loadStartAtRef.current = Date.now(); // Log stream information for debugging const streamInfo = { @@ -3193,7 +3422,7 @@ const AndroidVideoPlayer: React.FC = () => { }} onProgress={handleProgress} onLoad={(e) => { - console.log('🎬 [RN Video] Video loaded successfully'); + logger.log('[AndroidVideoPlayer][RN Video] Video loaded successfully'); logger.log('[AndroidVideoPlayer][RN Video] onLoad fired', { duration: e?.duration }); onLoad(e); }} @@ -3210,8 +3439,7 @@ const AndroidVideoPlayer: React.FC = () => { onSeek={onSeek} onEnd={onEnd} onError={(err) => { - console.log('🎬 [RN Video] Encountered error:', err); - logger.error('[AndroidVideoPlayer][RN Video] onError', err); + logger.error('[AndroidVideoPlayer][RN Video] Encountered error:', err); handleError(err); }} onBuffer={(buf) => { @@ -3229,7 +3457,7 @@ const AndroidVideoPlayer: React.FC = () => { playWhenInactive={false} ignoreSilentSwitch="ignore" mixWithOthers="inherit" - progressUpdateInterval={250} + progressUpdateInterval={500} // Remove artificial bit rate cap to allow high-bitrate streams (e.g., Blu-ray remux) to play // maxBitRate intentionally omitted disableFocus={true} diff --git a/src/components/player/utils/playerUtils.ts b/src/components/player/utils/playerUtils.ts index d7c79a56..53425990 100644 --- a/src/components/player/utils/playerUtils.ts +++ b/src/components/player/utils/playerUtils.ts @@ -101,7 +101,13 @@ export const getTrackDisplayName = (track: { name?: string, id: number, language return track.name; } - // If we have a language field, use that for better display + // If the track name contains detailed information (like codec, bitrate, etc.), use it as-is + if (track.name && (track.name.includes('DDP') || track.name.includes('DTS') || track.name.includes('AAC') || + track.name.includes('Kbps') || track.name.includes('Atmos') || track.name.includes('~'))) { + return track.name; + } + + // If we have a language field, use that for better display (only for simple track names) if (track.language && track.language !== 'Unknown') { const formattedLanguage = formatLanguage(track.language); if (formattedLanguage !== 'Unknown' && !formattedLanguage.includes('Unknown')) { diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 32c3bdb7..1587fb12 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -47,6 +47,7 @@ import QualityBadge from '../components/metadata/QualityBadge'; import { logger } from '../utils/logger'; import { isMkvStream } from '../utils/mkvDetection'; import CustomAlert from '../components/CustomAlert'; +import { toast, ToastPosition } from '@backpackapp-io/react-native-toast'; const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906'; const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png'; @@ -218,10 +219,31 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the if (stream.url) { try { await Clipboard.setString(stream.url); - showAlert('Copied!', 'Stream URL has been copied to clipboard.'); + + // Use toast for Android, custom alert for iOS + if (Platform.OS === 'android') { + toast('Stream URL copied to clipboard!', { + duration: 2000, + position: ToastPosition.BOTTOM, + }); + } else { + // iOS uses custom alert + setTimeout(() => { + showAlert('Copied!', 'Stream URL has been copied to clipboard.'); + }, 50); + } } catch (error) { // Fallback: show URL in alert if clipboard fails - showAlert('Stream URL', stream.url); + if (Platform.OS === 'android') { + toast(`Stream URL: ${stream.url}`, { + duration: 3000, + position: ToastPosition.BOTTOM, + }); + } else { + setTimeout(() => { + showAlert('Stream URL', stream.url); + }, 50); + } } } }, [stream.url, showAlert]); @@ -438,16 +460,25 @@ export const StreamsScreen = () => { const [alertMessage, setAlertMessage] = useState(''); const [alertActions, setAlertActions] = useState void; style?: object }>>([]); - const openAlert = ( + const openAlert = useCallback(( title: string, message: string, actions?: Array<{ label: string; onPress: () => void; style?: object }> ) => { - setAlertTitle(title); - setAlertMessage(message); - setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); - setAlertVisible(true); - }; + // Add safety check to prevent crashes on Android + if (!isMounted.current) { + return; + } + + try { + setAlertTitle(title); + setAlertMessage(message); + setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); + setAlertVisible(true); + } catch (error) { + console.warn('[StreamsScreen] Error showing alert:', error); + } + }, []); @@ -1927,13 +1958,15 @@ export const StreamsScreen = () => { )} - setAlertVisible(false)} - /> + {Platform.OS === 'ios' && ( + setAlertVisible(false)} + /> + )} ); };