diff --git a/android/app/build.gradle b/android/app/build.gradle index d8870d1..f852b41 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -246,6 +246,9 @@ dependencies { // Include only FFmpeg decoder AAR to avoid duplicates with Maven Media3 implementation files("libs/lib-decoder-ffmpeg-release.aar") + + // MPV Player library + implementation files("libs/libmpv-release.aar") // Google Cast Framework implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 63ac1b6..e241f30 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ - + + diff --git a/android/app/src/main/java/com/nuvio/app/MainApplication.kt b/android/app/src/main/java/com/nuvio/app/MainApplication.kt index 2a6f8de..9e0bf7b 100644 --- a/android/app/src/main/java/com/nuvio/app/MainApplication.kt +++ b/android/app/src/main/java/com/nuvio/app/MainApplication.kt @@ -24,7 +24,7 @@ class MainApplication : Application(), ReactApplication { override fun getPackages(): List = PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: - // add(MyReactNativePackage()) + add(com.nuvio.app.mpv.MpvPackage()) } override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" 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 new file mode 100644 index 0000000..e97fb31 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt @@ -0,0 +1,249 @@ +package com.nuvio.app.mpv + +import android.content.Context +import android.graphics.SurfaceTexture +import android.util.AttributeSet +import android.util.Log +import android.view.Surface +import android.view.TextureView +import dev.jdtech.mpv.MPVLib + +class MPVView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TextureView(context, attrs, defStyleAttr), TextureView.SurfaceTextureListener, MPVLib.EventObserver { + + companion object { + private const val TAG = "MPVView" + } + + private var isMpvInitialized = false + private var pendingDataSource: String? = null + private var isPaused: Boolean = true + private var surface: Surface? = null + + // Event listener for React Native + var onLoadCallback: ((duration: Double, width: Int, height: Int) -> Unit)? = null + var onProgressCallback: ((position: Double, duration: Double) -> Unit)? = null + var onEndCallback: (() -> Unit)? = null + var onErrorCallback: ((message: String) -> Unit)? = null + + init { + surfaceTextureListener = this + isOpaque = false + } + + override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + Log.d(TAG, "Surface texture available: ${width}x${height}") + try { + surface = Surface(surfaceTexture) + + MPVLib.create(context.applicationContext) + initOptions() + MPVLib.init() + MPVLib.attachSurface(surface!!) + MPVLib.addObserver(this) + MPVLib.setPropertyString("android-surface-size", "${width}x${height}") + observeProperties() + isMpvInitialized = true + + // If a data source was set before surface was ready, load it now + pendingDataSource?.let { url -> + loadFile(url) + pendingDataSource = null + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize MPV", e) + onErrorCallback?.invoke("MPV initialization failed: ${e.message}") + } + } + + override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) { + Log.d(TAG, "Surface texture size changed: ${width}x${height}") + if (isMpvInitialized) { + MPVLib.setPropertyString("android-surface-size", "${width}x${height}") + } + } + + override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { + Log.d(TAG, "Surface texture destroyed") + if (isMpvInitialized) { + MPVLib.removeObserver(this) + MPVLib.detachSurface() + MPVLib.destroy() + isMpvInitialized = false + } + surface?.release() + surface = null + return true + } + + override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { + // Called when the SurfaceTexture is updated via updateTexImage() + } + + private fun initOptions() { + // Mobile-optimized profile + MPVLib.setOptionString("profile", "fast") + MPVLib.setOptionString("vo", "gpu") + MPVLib.setOptionString("gpu-context", "android") + MPVLib.setOptionString("opengl-es", "yes") + MPVLib.setOptionString("hwdec", "mediacodec,mediacodec-copy") + MPVLib.setOptionString("hwdec-codecs", "h264,hevc,mpeg4,mpeg2video,vp8,vp9,av1") + 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("cache", "yes") + MPVLib.setOptionString("cache-secs", "30") + + // Disable terminal/input + MPVLib.setOptionString("terminal", "no") + MPVLib.setOptionString("input-default-bindings", "no") + } + + private fun observeProperties() { + // MPV format constants (from MPVLib source) + val MPV_FORMAT_NONE = 0 + val MPV_FORMAT_FLAG = 3 + val MPV_FORMAT_INT64 = 4 + val MPV_FORMAT_DOUBLE = 5 + + MPVLib.observeProperty("time-pos", MPV_FORMAT_DOUBLE) + MPVLib.observeProperty("duration", MPV_FORMAT_DOUBLE) + MPVLib.observeProperty("pause", MPV_FORMAT_FLAG) + MPVLib.observeProperty("paused-for-cache", MPV_FORMAT_FLAG) + MPVLib.observeProperty("eof-reached", MPV_FORMAT_FLAG) + MPVLib.observeProperty("video-params/aspect", MPV_FORMAT_DOUBLE) + MPVLib.observeProperty("width", MPV_FORMAT_INT64) + MPVLib.observeProperty("height", MPV_FORMAT_INT64) + MPVLib.observeProperty("track-list", MPV_FORMAT_NONE) + } + + private fun loadFile(url: String) { + Log.d(TAG, "Loading file: $url") + MPVLib.command(arrayOf("loadfile", url)) + } + + // Public API + + fun setDataSource(url: String) { + if (isMpvInitialized) { + loadFile(url) + } else { + pendingDataSource = url + } + } + + fun setPaused(paused: Boolean) { + isPaused = paused + if (isMpvInitialized) { + MPVLib.setPropertyBoolean("pause", paused) + } + } + + fun seekTo(positionSeconds: Double) { + if (isMpvInitialized) { + MPVLib.command(arrayOf("seek", positionSeconds.toString(), "absolute")) + } + } + + fun setSpeed(speed: Double) { + if (isMpvInitialized) { + MPVLib.setPropertyDouble("speed", speed) + } + } + + fun setVolume(volume: Double) { + if (isMpvInitialized) { + // MPV volume is 0-100 + MPVLib.setPropertyDouble("volume", volume * 100.0) + } + } + + fun setAudioTrack(trackId: Int) { + if (isMpvInitialized) { + if (trackId == -1) { + MPVLib.setPropertyString("aid", "no") + } else { + MPVLib.setPropertyInt("aid", trackId) + } + } + } + + fun setSubtitleTrack(trackId: Int) { + if (isMpvInitialized) { + if (trackId == -1) { + MPVLib.setPropertyString("sid", "no") + } else { + MPVLib.setPropertyInt("sid", trackId) + } + } + } + + // MPVLib.EventObserver implementation + + override fun eventProperty(property: String) { + Log.d(TAG, "Property changed: $property") + when (property) { + "track-list" -> { + // Track list updated, could notify JS about available tracks + } + } + } + + override fun eventProperty(property: String, value: Long) { + Log.d(TAG, "Property $property = $value (Long)") + } + + override fun eventProperty(property: String, value: Double) { + Log.d(TAG, "Property $property = $value (Double)") + when (property) { + "time-pos" -> { + val duration = MPVLib.getPropertyDouble("duration") ?: 0.0 + onProgressCallback?.invoke(value, duration) + } + "duration" -> { + val width = MPVLib.getPropertyInt("width") ?: 0 + val height = MPVLib.getPropertyInt("height") ?: 0 + onLoadCallback?.invoke(value, width, height) + } + } + } + + override fun eventProperty(property: String, value: Boolean) { + Log.d(TAG, "Property $property = $value (Boolean)") + when (property) { + "eof-reached" -> { + if (value) { + onEndCallback?.invoke() + } + } + } + } + + override fun eventProperty(property: String, value: String) { + Log.d(TAG, "Property $property = $value (String)") + } + + override fun event(eventId: Int) { + Log.d(TAG, "Event: $eventId") + // MPV event constants (from MPVLib source) + val MPV_EVENT_FILE_LOADED = 8 + val MPV_EVENT_END_FILE = 7 + + when (eventId) { + MPV_EVENT_FILE_LOADED -> { + // File is loaded, start playback if not paused + if (!isPaused) { + MPVLib.setPropertyBoolean("pause", false) + } + } + MPV_EVENT_END_FILE -> { + onEndCallback?.invoke() + } + } + } +} diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt b/android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt new file mode 100644 index 0000000..49c3dd2 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.mpv + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class MpvPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return emptyList() + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return listOf(MpvPlayerViewManager(reactContext)) + } +} diff --git a/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt new file mode 100644 index 0000000..822e529 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/mpv/MpvPlayerViewManager.kt @@ -0,0 +1,128 @@ +package com.nuvio.app.mpv + +import android.graphics.Color +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.common.MapBuilder +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.events.RCTEventEmitter + +class MpvPlayerViewManager( + private val reactContext: ReactApplicationContext +) : SimpleViewManager() { + + companion object { + const val REACT_CLASS = "MpvPlayer" + + // Commands + const val COMMAND_SEEK = 1 + const val COMMAND_SET_AUDIO_TRACK = 2 + const val COMMAND_SET_SUBTITLE_TRACK = 3 + } + + override fun getName(): String = REACT_CLASS + + override fun createViewInstance(context: ThemedReactContext): MPVView { + val view = MPVView(context) + // Note: Do NOT set background color - it will block the SurfaceView content + + // Set up event callbacks + view.onLoadCallback = { duration, width, height -> + val event = Arguments.createMap().apply { + putDouble("duration", duration) + putInt("width", width) + putInt("height", height) + } + sendEvent(context, view.id, "onLoad", event) + } + + view.onProgressCallback = { position, duration -> + val event = Arguments.createMap().apply { + putDouble("currentTime", position) + putDouble("duration", duration) + } + sendEvent(context, view.id, "onProgress", event) + } + + view.onEndCallback = { + sendEvent(context, view.id, "onEnd", Arguments.createMap()) + } + + view.onErrorCallback = { message -> + val event = Arguments.createMap().apply { + putString("error", message) + } + sendEvent(context, view.id, "onError", event) + } + + return view + } + + private fun sendEvent(context: ThemedReactContext, viewId: Int, eventName: String, params: com.facebook.react.bridge.WritableMap) { + context.getJSModule(RCTEventEmitter::class.java) + .receiveEvent(viewId, eventName, params) + } + + override fun getExportedCustomBubblingEventTypeConstants(): Map { + return MapBuilder.builder() + .put("onLoad", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onLoad"))) + .put("onProgress", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onProgress"))) + .put("onEnd", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onEnd"))) + .put("onError", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onError"))) + .build() + } + + override fun getCommandsMap(): Map { + return MapBuilder.of( + "seek", COMMAND_SEEK, + "setAudioTrack", COMMAND_SET_AUDIO_TRACK, + "setSubtitleTrack", COMMAND_SET_SUBTITLE_TRACK + ) + } + + override fun receiveCommand(view: MPVView, commandId: String?, args: ReadableArray?) { + when (commandId) { + "seek" -> { + args?.getDouble(0)?.let { view.seekTo(it) } + } + "setAudioTrack" -> { + args?.getInt(0)?.let { view.setAudioTrack(it) } + } + "setSubtitleTrack" -> { + args?.getInt(0)?.let { view.setSubtitleTrack(it) } + } + } + } + + // React Props + + @ReactProp(name = "source") + fun setSource(view: MPVView, source: String?) { + source?.let { view.setDataSource(it) } + } + + @ReactProp(name = "paused") + fun setPaused(view: MPVView, paused: Boolean) { + view.setPaused(paused) + } + + @ReactProp(name = "volume", defaultFloat = 1.0f) + fun setVolume(view: MPVView, volume: Float) { + view.setVolume(volume.toDouble()) + } + + @ReactProp(name = "rate", defaultFloat = 1.0f) + fun setRate(view: MPVView, rate: Float) { + view.setSpeed(rate.toDouble()) + } + + // Handle backgroundColor prop to prevent crash from React Native style system + @ReactProp(name = "backgroundColor", customType = "Color") + fun setBackgroundColor(view: MPVView, color: Int?) { + // Intentionally ignoring - background color would block the TextureView content + // Leave the view transparent + } +} diff --git a/package-lock.json b/package-lock.json index 9f09ce7..b4c0c06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "expo-auth-session": "~7.0.8", "expo-blur": "~15.0.7", "expo-brightness": "~14.0.7", + "expo-clipboard": "~8.0.8", "expo-crypto": "~15.0.7", "expo-dev-client": "~6.0.15", "expo-device": "~8.0.9", @@ -53,6 +54,7 @@ "expo-libvlc-player": "^2.2.3", "expo-linear-gradient": "~15.0.7", "expo-localization": "~17.0.7", + "expo-navigation-bar": "~5.0.10", "expo-notifications": "~0.32.12", "expo-random": "^14.0.1", "expo-screen-orientation": "~9.0.7", @@ -6326,6 +6328,17 @@ "react-native": "*" } }, + "node_modules/expo-clipboard": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz", + "integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.12", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz", @@ -6590,6 +6603,22 @@ "react-native": "*" } }, + "node_modules/expo-navigation-bar": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/expo-navigation-bar/-/expo-navigation-bar-5.0.10.tgz", + "integrity": "sha512-r9rdLw8mY6GPMQmVVOY/r1NBBw74DZefXHF60HxhRsdNI2kjc1wLdfWfR2rk4JVdOvdMDujnGrc9HQmqM3n8Jg==", + "license": "MIT", + "dependencies": { + "@react-native/normalize-colors": "0.81.5", + "debug": "^4.3.2", + "react-native-is-edge-to-edge": "^1.2.1" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-notifications": { "version": "0.32.15", "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.15.tgz", diff --git a/package.json b/package.json index e74ba9b..e4f9750 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "expo-auth-session": "~7.0.8", "expo-blur": "~15.0.7", "expo-brightness": "~14.0.7", + "expo-clipboard": "~8.0.8", "expo-crypto": "~15.0.7", "expo-dev-client": "~6.0.15", "expo-device": "~8.0.9", @@ -53,6 +54,7 @@ "expo-libvlc-player": "^2.2.3", "expo-linear-gradient": "~15.0.7", "expo-localization": "~17.0.7", + "expo-navigation-bar": "~5.0.10", "expo-notifications": "~0.32.12", "expo-random": "^14.0.1", "expo-screen-orientation": "~9.0.7", diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index c2142be..72a19b2 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react'; import { View, StyleSheet, Platform, Animated } from 'react-native'; +import { toast } from '@backpackapp-io/react-native-toast'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; @@ -349,8 +350,35 @@ const AndroidVideoPlayer: React.FC = () => { if (modals.showEpisodeStreamsModal) return; playerState.setPaused(true); }} - onError={(err) => { + onError={(err: any) => { logger.error('Video Error', err); + + // Check for decoding errors to switch to VLC + const errorString = err?.errorString || err?.error?.errorString; + const errorCode = err?.errorCode || err?.error?.errorCode; + const causeMessage = err?.error?.cause?.message; + + const isDecodingError = + (errorString && errorString.includes('ERROR_CODE_DECODING_FAILED')) || + errorCode === '24003' || + (causeMessage && causeMessage.includes('MediaCodecVideoRenderer error')); + + if (!useVLC && isDecodingError) { + const toastId = toast.loading('Decoding error. Switching to VLC Player...'); + setTimeout(() => toast.dismiss(toastId), 3000); + + // We can just show a normal toast or use the existing modal system if we want, + // but checking the file imports, I don't see Toast imported. + // Let's implement the navigation replace. + + // Using a simple navigation replace to force VLC + (navigation as any).replace('PlayerAndroid', { + ...route.params, + forceVlc: true + }); + return; + } + modals.setErrorDetails(JSON.stringify(err)); modals.setShowErrorModal(true); }} diff --git a/src/components/player/android/MpvPlayer.tsx b/src/components/player/android/MpvPlayer.tsx new file mode 100644 index 0000000..df1868f --- /dev/null +++ b/src/components/player/android/MpvPlayer.tsx @@ -0,0 +1,117 @@ +import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'; +import { View, StyleSheet, requireNativeComponent, Platform, UIManager, findNodeHandle } from 'react-native'; + +// Only available on Android +const MpvPlayerNative = Platform.OS === 'android' + ? requireNativeComponent('MpvPlayer') + : null; + +export interface MpvPlayerRef { + seek: (positionSeconds: number) => void; + setAudioTrack: (trackId: number) => void; + setSubtitleTrack: (trackId: number) => void; +} + +export interface MpvPlayerProps { + source: string; + paused?: boolean; + volume?: number; + rate?: number; + style?: any; + onLoad?: (data: { duration: number; width: number; height: number }) => void; + onProgress?: (data: { currentTime: number; duration: number }) => void; + onEnd?: () => void; + onError?: (error: { error: string }) => void; +} + +const MpvPlayer = forwardRef((props, ref) => { + const nativeRef = useRef(null); + + const dispatchCommand = useCallback((commandName: string, args: any[] = []) => { + if (nativeRef.current && Platform.OS === 'android') { + const handle = findNodeHandle(nativeRef.current); + if (handle) { + UIManager.dispatchViewManagerCommand( + handle, + commandName, + args + ); + } + } + }, []); + + useImperativeHandle(ref, () => ({ + seek: (positionSeconds: number) => { + dispatchCommand('seek', [positionSeconds]); + }, + setAudioTrack: (trackId: number) => { + dispatchCommand('setAudioTrack', [trackId]); + }, + setSubtitleTrack: (trackId: number) => { + dispatchCommand('setSubtitleTrack', [trackId]); + }, + }), [dispatchCommand]); + + if (Platform.OS !== 'android' || !MpvPlayerNative) { + // Fallback for iOS or if native component is not available + return ( + + ); + } + + console.log('[MpvPlayer] Rendering native component with:', { + source: props.source?.substring(0, 50) + '...', + paused: props.paused ?? true, + volume: props.volume ?? 1.0, + rate: props.rate ?? 1.0, + }); + + const handleLoad = (event: any) => { + console.log('[MpvPlayer] Native onLoad event:', event?.nativeEvent); + props.onLoad?.(event?.nativeEvent); + }; + + const handleProgress = (event: any) => { + const data = event?.nativeEvent; + if (data && Math.floor(data.currentTime) % 5 === 0) { + console.log('[MpvPlayer] Native onProgress event:', data); + } + props.onProgress?.(data); + }; + + const handleEnd = (event: any) => { + console.log('[MpvPlayer] Native onEnd event'); + props.onEnd?.(); + }; + + const handleError = (event: any) => { + console.log('[MpvPlayer] Native onError event:', event?.nativeEvent); + props.onError?.(event?.nativeEvent); + }; + + return ( + + ); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'black', + }, +}); + +MpvPlayer.displayName = 'MpvPlayer'; + +export default MpvPlayer; diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index 41273e4..8b98dcb 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -1,52 +1,18 @@ -import React, { forwardRef } from 'react'; -import { View, TouchableOpacity, StyleSheet, Platform } from 'react-native'; -import Video, { ViewType, VideoRef, ResizeMode } from 'react-native-video'; -import VlcVideoPlayer, { VlcPlayerRef } from '../../VlcVideoPlayer'; +import React from 'react'; +import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native'; import { PinchGestureHandler } from 'react-native-gesture-handler'; +import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer'; import { styles } from '../../utils/playerStyles'; +import { ResizeModeType } from '../../utils/playerTypes'; import { logger } from '../../../../utils/logger'; -import { ResizeModeType, SelectedTrack } from '../../utils/playerTypes'; - -const getVideoResizeMode = (resizeMode: ResizeModeType) => { - switch (resizeMode) { - case 'contain': return 'contain'; - case 'cover': return 'cover'; - case 'stretch': return 'contain'; - case 'none': return 'contain'; - default: return 'contain'; - } -}; - -// VLC only supports 'contain' | 'cover' | 'none' -const getVlcResizeMode = (resizeMode: ResizeModeType): 'contain' | 'cover' | 'none' => { - switch (resizeMode) { - case 'contain': return 'contain'; - case 'cover': return 'cover'; - case 'stretch': return 'cover'; // stretch is not supported, use cover - case 'none': return 'none'; - default: return 'contain'; - } -}; interface VideoSurfaceProps { - useVLC: boolean; - forceVlcRemount: boolean; processedStreamUrl: string; volume: number; playbackSpeed: number; - zoomScale: number; resizeMode: ResizeModeType; paused: boolean; currentStreamUrl: string; - headers: any; - videoType: any; - vlcSelectedAudioTrack?: number; - vlcSelectedSubtitleTrack?: number; - vlcRestoreTime?: number; - vlcKey: string; - selectedAudioTrack: any; - selectedTextTrack: any; - useCustomSubtitles: boolean; // Callbacks toggleControls: () => void; @@ -56,44 +22,45 @@ interface VideoSurfaceProps { onEnd: () => void; onError: (err: any) => void; onBuffer: (buf: any) => void; - onTracksUpdate: (tracks: any) => void; // Refs - vlcPlayerRef: React.RefObject; - videoRef: React.RefObject; + mpvPlayerRef?: React.RefObject; pinchRef: any; // Handlers onPinchGestureEvent: any; onPinchHandlerStateChange: any; - vlcLoadedRef: React.MutableRefObject; screenDimensions: { width: number, height: number }; - customVideoStyles: any; - // Debugging - loadStartAtRef: React.MutableRefObject; - firstFrameAtRef: React.MutableRefObject; + // Legacy props (kept for compatibility but unused with MPV) + useVLC?: boolean; + forceVlcRemount?: boolean; + headers?: any; + videoType?: any; + vlcSelectedAudioTrack?: number; + vlcSelectedSubtitleTrack?: number; + vlcRestoreTime?: number; + vlcKey?: string; + selectedAudioTrack?: any; + selectedTextTrack?: any; + useCustomSubtitles?: boolean; + onTracksUpdate?: (tracks: any) => void; + vlcPlayerRef?: any; + videoRef?: any; + vlcLoadedRef?: any; + customVideoStyles?: any; + loadStartAtRef?: any; + firstFrameAtRef?: any; + zoomScale?: number; } export const VideoSurface: React.FC = ({ - useVLC, - forceVlcRemount, processedStreamUrl, volume, playbackSpeed, - zoomScale, resizeMode, paused, currentStreamUrl, - headers, - videoType, - vlcSelectedAudioTrack, - vlcSelectedSubtitleTrack, - vlcRestoreTime, - vlcKey, - selectedAudioTrack, - selectedTextTrack, - useCustomSubtitles, toggleControls, onLoad, onProgress, @@ -101,23 +68,57 @@ export const VideoSurface: React.FC = ({ onEnd, onError, onBuffer, - onTracksUpdate, - vlcPlayerRef, - videoRef, + mpvPlayerRef, pinchRef, onPinchGestureEvent, onPinchHandlerStateChange, - vlcLoadedRef, screenDimensions, - customVideoStyles, - loadStartAtRef, - firstFrameAtRef }) => { + // Use the actual stream URL + const streamUrl = currentStreamUrl || processedStreamUrl; - const isHlsStream = (url: string) => { - return url.includes('.m3u8') || url.includes('m3u8') || - url.includes('hls') || url.includes('playlist') || - (videoType && videoType.toLowerCase() === 'm3u8'); + console.log('[VideoSurface] Rendering with:', { + streamUrl: streamUrl?.substring(0, 50) + '...', + paused, + volume, + playbackSpeed, + screenDimensions, + }); + + const handleLoad = (data: { duration: number; width: number; height: number }) => { + console.log('[VideoSurface] onLoad received:', data); + onLoad({ + duration: data.duration, + naturalSize: { + width: data.width, + height: data.height, + }, + }); + }; + + const handleProgress = (data: { currentTime: number; duration: number }) => { + // Log every 5 seconds to avoid spam + if (Math.floor(data.currentTime) % 5 === 0) { + console.log('[VideoSurface] onProgress:', data); + } + onProgress({ + currentTime: data.currentTime, + playableDuration: data.currentTime, + }); + }; + + const handleError = (error: { error: string }) => { + console.log('[VideoSurface] onError received:', error); + onError({ + error: { + errorString: error.error, + }, + }); + }; + + const handleEnd = () => { + console.log('[VideoSurface] onEnd received'); + onEnd(); }; return ( @@ -125,96 +126,53 @@ export const VideoSurface: React.FC = ({ width: screenDimensions.width, height: screenDimensions.height, }]}> + {/* MPV Player - rendered at the bottom of the z-order */} + + + {/* Gesture overlay - transparent, on top of the player */} - - - {useVLC && !forceVlcRemount ? ( - { - vlcLoadedRef.current = true; - onLoad(data); - if (!paused && vlcPlayerRef.current) { - setTimeout(() => { - if (vlcPlayerRef.current) { - vlcPlayerRef.current.play(); - } - }, 100); - } - }} - onProgress={onProgress} - onSeek={onSeek} - onEnd={onEnd} - onError={onError} - onTracksUpdate={onTracksUpdate} - selectedAudioTrack={vlcSelectedAudioTrack} - selectedSubtitleTrack={vlcSelectedSubtitleTrack} - restoreTime={vlcRestoreTime} - forceRemount={forceVlcRemount} - key={vlcKey} - /> - ) : ( - + + + + ); }; + +const localStyles = StyleSheet.create({ + player: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + gestureOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + touchArea: { + flex: 1, + backgroundColor: 'transparent', + }, +}); diff --git a/src/components/player/android/hooks/usePlayerSetup.ts b/src/components/player/android/hooks/usePlayerSetup.ts index 6b5e00b..4dde019 100644 --- a/src/components/player/android/hooks/usePlayerSetup.ts +++ b/src/components/player/android/hooks/usePlayerSetup.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react'; import { StatusBar, Platform, Dimensions, AppState } from 'react-native'; import RNImmersiveMode from 'react-native-immersive-mode'; +import * as NavigationBar from 'expo-navigation-bar'; import * as Brightness from 'expo-brightness'; import { logger } from '../../../../utils/logger'; import { useFocusEffect } from '@react-navigation/native'; @@ -18,19 +19,34 @@ export const usePlayerSetup = ( const originalSystemBrightnessModeRef = useRef(null); const isAppBackgrounded = useRef(false); - const enableImmersiveMode = () => { + const enableImmersiveMode = async () => { if (Platform.OS === 'android') { + // Standard immersive mode RNImmersiveMode.setBarTranslucent(true); RNImmersiveMode.fullLayout(true); StatusBar.setHidden(true, 'none'); + + // Explicitly hide bottom navigation bar using Expo + try { + await NavigationBar.setVisibilityAsync("hidden"); + await NavigationBar.setBehaviorAsync("overlay-swipe"); + } catch (e) { + // Ignore errors on non-supported devices + } } }; - const disableImmersiveMode = () => { + const disableImmersiveMode = async () => { if (Platform.OS === 'android') { RNImmersiveMode.setBarTranslucent(false); RNImmersiveMode.fullLayout(false); StatusBar.setHidden(false, 'fade'); + + try { + await NavigationBar.setVisibilityAsync("visible"); + } catch (e) { + // Ignore + } } }; diff --git a/src/components/player/modals/ErrorModal.tsx b/src/components/player/modals/ErrorModal.tsx index 174f934..a1eda32 100644 --- a/src/components/player/modals/ErrorModal.tsx +++ b/src/components/player/modals/ErrorModal.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import * as ExpoClipboard from 'expo-clipboard'; import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import Animated, { @@ -21,6 +22,7 @@ export const ErrorModal: React.FC = ({ errorDetails, onDismiss, }) => { + const [copied, setCopied] = React.useState(false); const { width } = useWindowDimensions(); const MODAL_WIDTH = Math.min(width * 0.8, 400); @@ -31,6 +33,12 @@ export const ErrorModal: React.FC = ({ } }; + const handleCopy = async () => { + await ExpoClipboard.setStringAsync(errorDetails); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + if (!showErrorModal) return null; return ( @@ -74,16 +82,42 @@ export const ErrorModal: React.FC = ({ Playback Error - + {errorDetails || 'An unknown error occurred during playback.'} + + + + {copied ? 'Copied to clipboard' : 'Copy error details'} + + +