diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt index 43e32c6..96d6718 100644 --- a/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -93,95 +93,70 @@ class MPVView @JvmOverloads constructor( } private fun initOptions() { - // Mobile-optimized profile MPVLib.setOptionString("profile", "fast") MPVLib.setOptionString("vo", "gpu") MPVLib.setOptionString("gpu-context", "android") MPVLib.setOptionString("opengl-es", "yes") - // Hardware decoding configuration - // 'mediacodec-copy' for hardware acceleration (GPU decoding, copies frames to CPU) - // 'no' for software decoding (more compatible, especially on emulators) - val hwdecValue = if (useHardwareDecoding) "mediacodec-copy" else "no" + val hwdecValue = if (useHardwareDecoding) "mediacodec,mediacodec-copy" else "no" Log.d(TAG, "Hardware decoding: $useHardwareDecoding, hwdec value: $hwdecValue") MPVLib.setOptionString("hwdec", hwdecValue) - MPVLib.setOptionString("hwdec-codecs", "all") + MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") + + MPVLib.setOptionString("target-colorspace-hint", "yes") + MPVLib.setOptionString("vd-lavc-film-grain", "cpu") - // Audio output MPVLib.setOptionString("ao", "audiotrack,opensles") - // Network caching for streaming - MPVLib.setOptionString("demuxer-max-bytes", "67108864") // 64MB - MPVLib.setOptionString("demuxer-max-back-bytes", "33554432") // 32MB + MPVLib.setOptionString("demuxer-max-bytes", "67108864") + MPVLib.setOptionString("demuxer-max-back-bytes", "33554432") MPVLib.setOptionString("cache", "yes") MPVLib.setOptionString("cache-secs", "30") - // Network options - MPVLib.setOptionString("network-timeout", "60") // 60 second timeout - - // CRITICAL: Disable youtube-dl/yt-dlp hook - // The ytdl_hook incorrectly tries to parse HLS/direct URLs through youtube-dl - // which fails on Android since yt-dlp is not available, causing playback failure + MPVLib.setOptionString("network-timeout", "60") MPVLib.setOptionString("ytdl", "no") - // CRITICAL: HTTP headers MUST be set as options before init() - // Apply headers if they were set before surface initialization applyHttpHeadersAsOptions() - // FFmpeg HTTP protocol options for better compatibility - MPVLib.setOptionString("tls-verify", "no") // Disable TLS cert verification - MPVLib.setOptionString("http-reconnect", "yes") // Auto-reconnect on network issues - MPVLib.setOptionString("stream-reconnect", "yes") // Reconnect if stream drops + MPVLib.setOptionString("tls-verify", "no") + MPVLib.setOptionString("http-reconnect", "yes") + MPVLib.setOptionString("stream-reconnect", "yes") - // CRITICAL: HLS demuxer options for proper VOD stream handling - // Without these, HLS streams may be treated as live and start from the end - // Note: Multiple lavf options separated by comma MPVLib.setOptionString("demuxer-lavf-o", "live_start_index=0,prefer_x_start=1,http_persistent=0") - MPVLib.setOptionString("demuxer-seekable-cache", "yes") // Allow seeking in cached content - MPVLib.setOptionString("force-seekable", "yes") // Force stream to be seekable + MPVLib.setOptionString("demuxer-seekable-cache", "yes") + MPVLib.setOptionString("force-seekable", "yes") - // Increase probe/analyze duration to help detect full HLS duration - MPVLib.setOptionString("demuxer-lavf-probesize", "10000000") // 10MB probe size - MPVLib.setOptionString("demuxer-lavf-analyzeduration", "10") // 10 seconds analyze + MPVLib.setOptionString("demuxer-lavf-probesize", "10000000") + MPVLib.setOptionString("demuxer-lavf-analyzeduration", "10") - // Subtitle configuration - CRITICAL for Android - MPVLib.setOptionString("sub-auto", "fuzzy") // Auto-load subtitles - MPVLib.setOptionString("sub-visibility", "yes") // Make subtitles visible by default - MPVLib.setOptionString("sub-font-size", "48") // Larger font size for mobile readability - MPVLib.setOptionString("sub-pos", "95") // Position at bottom (0-100, 100 = very bottom) - MPVLib.setOptionString("sub-color", "#FFFFFFFF") // White color - MPVLib.setOptionString("sub-border-size", "3") // Thicker border for readability - MPVLib.setOptionString("sub-border-color", "#FF000000") // Black border - MPVLib.setOptionString("sub-shadow-offset", "2") // Add shadow for better visibility - MPVLib.setOptionString("sub-shadow-color", "#80000000") // Semi-transparent black shadow + MPVLib.setOptionString("sub-auto", "fuzzy") + MPVLib.setOptionString("sub-visibility", "yes") + MPVLib.setOptionString("sub-font-size", "48") + MPVLib.setOptionString("sub-pos", "95") + MPVLib.setOptionString("sub-color", "#FFFFFFFF") + MPVLib.setOptionString("sub-border-size", "3") + MPVLib.setOptionString("sub-border-color", "#FF000000") + MPVLib.setOptionString("sub-shadow-offset", "2") + MPVLib.setOptionString("sub-shadow-color", "#80000000") - // Font configuration - point to Android system fonts for all language support MPVLib.setOptionString("osd-fonts-dir", "/system/fonts") MPVLib.setOptionString("sub-fonts-dir", "/system/fonts") - MPVLib.setOptionString("sub-font", "Roboto") // Default fallback font - // Allow embedded fonts in ASS/SSA but fallback to system fonts + MPVLib.setOptionString("sub-font", "Roboto") MPVLib.setOptionString("embeddedfonts", "yes") - // Language/encoding support for various subtitle formats - MPVLib.setOptionString("sub-codepage", "auto") // Auto-detect encoding (supports UTF-8, Latin, CJK, etc.) + MPVLib.setOptionString("sub-codepage", "auto") - MPVLib.setOptionString("osc", "no") // Disable on screen controller + MPVLib.setOptionString("osc", "no") MPVLib.setOptionString("osd-level", "1") - // Critical for subtitle rendering on Android GPU - // blend-subtitles=no lets the GPU renderer handle subtitle overlay properly MPVLib.setOptionString("blend-subtitles", "no") MPVLib.setOptionString("sub-use-margins", "no") - // Use 'scale' to allow ASS styling but with our scale and font overrides - // This preserves styled subtitles while having font fallbacks MPVLib.setOptionString("sub-ass-override", "scale") MPVLib.setOptionString("sub-scale", "1.0") - MPVLib.setOptionString("sub-fix-timing", "yes") // Fix timing for SRT subtitles + MPVLib.setOptionString("sub-fix-timing", "yes") - // Force subtitle rendering - MPVLib.setOptionString("sid", "auto") // Auto-select subtitle track + MPVLib.setOptionString("sid", "auto") - // Disable terminal/input MPVLib.setOptionString("terminal", "no") MPVLib.setOptionString("input-default-bindings", "no") } diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index f17d1e9..11d824f 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -76,6 +76,7 @@ const AndroidVideoPlayer: React.FC = () => { const videoRef = useRef(null); const mpvPlayerRef = useRef(null); + const pinchRef = useRef(null); const tracksHook = usePlayerTracks(); const [currentStreamUrl, setCurrentStreamUrl] = useState(uri); @@ -86,6 +87,9 @@ const AndroidVideoPlayer: React.FC = () => { const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider); const [currentStreamName, setCurrentStreamName] = useState(streamName); + // State to force unmount VideoSurface during stream transitions + const [isTransitioningStream, setIsTransitioningStream] = useState(false); + // Subtitle addon state const [availableSubtitles, setAvailableSubtitles] = useState([]); const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false); @@ -329,10 +333,14 @@ const AndroidVideoPlayer: React.FC = () => { modals.setShowSourcesModal(false); playerState.setPaused(true); + // Unmount VideoSurface first to ensure MPV is fully destroyed + setIsTransitioningStream(true); + const newQuality = newStream.quality || newStream.title?.match(/(\d+)p/)?.[0]; const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown'; const newStreamName = newStream.name || newStream.title || 'Unknown'; + // Wait for unmount to complete, then navigate setTimeout(() => { (navigation as any).replace('PlayerAndroid', { ...route.params, @@ -343,19 +351,24 @@ const AndroidVideoPlayer: React.FC = () => { headers: newStream.headers, availableStreams: availableStreams }); - }, 100); + }, 300); }; const handleEpisodeStreamSelect = async (stream: any) => { if (!modals.selectedEpisodeForStreams) return; modals.setShowEpisodeStreamsModal(false); playerState.setPaused(true); + + // Unmount VideoSurface first to ensure MPV is fully destroyed + setIsTransitioningStream(true); + const ep = modals.selectedEpisodeForStreams; const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]); const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown'; const newStreamName = stream.name || stream.title || 'Unknown Stream'; + // Wait for unmount to complete, then navigate setTimeout(() => { (navigation as any).replace('PlayerAndroid', { uri: stream.url, @@ -376,7 +389,7 @@ const AndroidVideoPlayer: React.FC = () => { availableStreams: {}, groupedEpisodes: groupedEpisodes, }); - }, 100); + }, 300); }; // Subtitle addon fetching @@ -489,73 +502,75 @@ const AndroidVideoPlayer: React.FC = () => { /> - { - playerState.isSeeking.current = false; - if (data.currentTime) traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true); - }} - onEnd={() => { - if (modals.showEpisodeStreamsModal) return; - playerState.setPaused(true); - }} - onError={(err: any) => { - logger.error('Video Error', err); + {!isTransitioningStream && ( + { + playerState.isSeeking.current = false; + if (data.currentTime) traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true); + }} + onEnd={() => { + if (modals.showEpisodeStreamsModal) return; + playerState.setPaused(true); + }} + onError={(err: any) => { + logger.error('Video Error', err); - // Determine the actual error message - let displayError = 'An unknown error occurred'; + // Determine the actual error message + let displayError = 'An unknown error occurred'; - if (typeof err?.error === 'string') { - displayError = err.error; - } else if (err?.error?.errorString) { - displayError = err.error.errorString; - } else if (err?.errorString) { - displayError = err.errorString; - } else if (typeof err === 'string') { - displayError = err; - } else { - displayError = JSON.stringify(err); - } + if (typeof err?.error === 'string') { + displayError = err.error; + } else if (err?.error?.errorString) { + displayError = err.error.errorString; + } else if (err?.errorString) { + displayError = err.errorString; + } else if (typeof err === 'string') { + displayError = err; + } else { + displayError = JSON.stringify(err); + } - modals.setErrorDetails(displayError); - modals.setShowErrorModal(true); - }} - onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)} - onTracksChanged={(data) => { - console.log('[AndroidVideoPlayer] onTracksChanged:', data); - if (data?.audioTracks) { - const formatted = data.audioTracks.map((t: any) => ({ - id: t.id, - name: t.name || `Track ${t.id}`, - language: t.language - })); - tracksHook.setRnVideoAudioTracks(formatted); - } - if (data?.subtitleTracks) { - const formatted = data.subtitleTracks.map((t: any) => ({ - id: t.id, - name: t.name || `Track ${t.id}`, - language: t.language - })); - tracksHook.setRnVideoTextTracks(formatted); - } - }} - mpvPlayerRef={mpvPlayerRef} - pinchRef={useRef(null)} - onPinchGestureEvent={() => { }} - onPinchHandlerStateChange={() => { }} - screenDimensions={playerState.screenDimensions} - useHardwareDecoding={settings.useHardwareDecoding} - /> + modals.setErrorDetails(displayError); + modals.setShowErrorModal(true); + }} + onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)} + onTracksChanged={(data) => { + console.log('[AndroidVideoPlayer] onTracksChanged:', data); + if (data?.audioTracks) { + const formatted = data.audioTracks.map((t: any) => ({ + id: t.id, + name: t.name || `Track ${t.id}`, + language: t.language + })); + tracksHook.setRnVideoAudioTracks(formatted); + } + if (data?.subtitleTracks) { + const formatted = data.subtitleTracks.map((t: any) => ({ + id: t.id, + name: t.name || `Track ${t.id}`, + language: t.language + })); + tracksHook.setRnVideoTextTracks(formatted); + } + }} + mpvPlayerRef={mpvPlayerRef} + pinchRef={pinchRef} + onPinchGestureEvent={() => { }} + onPinchHandlerStateChange={() => { }} + screenDimensions={playerState.screenDimensions} + useHardwareDecoding={settings.useHardwareDecoding} + /> + )} {/* Custom Subtitles for addon subtitles */} = ({ shouldShow, }) => { const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); const screenWidth = Dimensions.get('window').width; const [warnings, setWarnings] = useState([]); const [isVisible, setIsVisible] = useState(false); @@ -231,8 +233,12 @@ export const ParentalGuideOverlay: React.FC = ({ const lineWidth = Math.min(3, screenWidth * 0.0038); const containerPadding = Math.min(20, screenWidth * 0.025); + // Use left inset for landscape notches, top inset for portrait + const safeLeftOffset = insets.left + containerPadding; + const safeTopOffset = containerPadding; + return ( - + {/* Vertical line - animates height */}