mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-01 13:14:50 +00:00
mpv init
This commit is contained in:
parent
967b90b98e
commit
19438ff1d5
13 changed files with 746 additions and 164 deletions
|
|
@ -247,6 +247,9 @@ dependencies {
|
||||||
// Include only FFmpeg decoder AAR to avoid duplicates with Maven Media3
|
// Include only FFmpeg decoder AAR to avoid duplicates with Maven Media3
|
||||||
implementation files("libs/lib-decoder-ffmpeg-release.aar")
|
implementation files("libs/lib-decoder-ffmpeg-release.aar")
|
||||||
|
|
||||||
|
// MPV Player library
|
||||||
|
implementation files("libs/libmpv-release.aar")
|
||||||
|
|
||||||
// Google Cast Framework
|
// Google Cast Framework
|
||||||
implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}"
|
implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv"/>
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class MainApplication : Application(), ReactApplication {
|
||||||
override fun getPackages(): List<ReactPackage> =
|
override fun getPackages(): List<ReactPackage> =
|
||||||
PackageList(this).packages.apply {
|
PackageList(this).packages.apply {
|
||||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
// 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"
|
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
|
||||||
|
|
|
||||||
249
android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt
Normal file
249
android/app/src/main/java/com/nuvio/app/mpv/MPVView.kt
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt
Normal file
16
android/app/src/main/java/com/nuvio/app/mpv/MpvPackage.kt
Normal file
|
|
@ -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<NativeModule> {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||||
|
return listOf(MpvPlayerViewManager(reactContext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<MPVView>() {
|
||||||
|
|
||||||
|
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<String, Any> {
|
||||||
|
return MapBuilder.builder<String, Any>()
|
||||||
|
.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<String, Int> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
29
package-lock.json
generated
29
package-lock.json
generated
|
|
@ -42,6 +42,7 @@
|
||||||
"expo-auth-session": "~7.0.8",
|
"expo-auth-session": "~7.0.8",
|
||||||
"expo-blur": "~15.0.7",
|
"expo-blur": "~15.0.7",
|
||||||
"expo-brightness": "~14.0.7",
|
"expo-brightness": "~14.0.7",
|
||||||
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-crypto": "~15.0.7",
|
"expo-crypto": "~15.0.7",
|
||||||
"expo-dev-client": "~6.0.15",
|
"expo-dev-client": "~6.0.15",
|
||||||
"expo-device": "~8.0.9",
|
"expo-device": "~8.0.9",
|
||||||
|
|
@ -53,6 +54,7 @@
|
||||||
"expo-libvlc-player": "^2.2.3",
|
"expo-libvlc-player": "^2.2.3",
|
||||||
"expo-linear-gradient": "~15.0.7",
|
"expo-linear-gradient": "~15.0.7",
|
||||||
"expo-localization": "~17.0.7",
|
"expo-localization": "~17.0.7",
|
||||||
|
"expo-navigation-bar": "~5.0.10",
|
||||||
"expo-notifications": "~0.32.12",
|
"expo-notifications": "~0.32.12",
|
||||||
"expo-random": "^14.0.1",
|
"expo-random": "^14.0.1",
|
||||||
"expo-screen-orientation": "~9.0.7",
|
"expo-screen-orientation": "~9.0.7",
|
||||||
|
|
@ -6326,6 +6328,17 @@
|
||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-constants": {
|
||||||
"version": "18.0.12",
|
"version": "18.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.12.tgz",
|
||||||
|
|
@ -6590,6 +6603,22 @@
|
||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-notifications": {
|
||||||
"version": "0.32.15",
|
"version": "0.32.15",
|
||||||
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.15.tgz",
|
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.15.tgz",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
"expo-auth-session": "~7.0.8",
|
"expo-auth-session": "~7.0.8",
|
||||||
"expo-blur": "~15.0.7",
|
"expo-blur": "~15.0.7",
|
||||||
"expo-brightness": "~14.0.7",
|
"expo-brightness": "~14.0.7",
|
||||||
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-crypto": "~15.0.7",
|
"expo-crypto": "~15.0.7",
|
||||||
"expo-dev-client": "~6.0.15",
|
"expo-dev-client": "~6.0.15",
|
||||||
"expo-device": "~8.0.9",
|
"expo-device": "~8.0.9",
|
||||||
|
|
@ -53,6 +54,7 @@
|
||||||
"expo-libvlc-player": "^2.2.3",
|
"expo-libvlc-player": "^2.2.3",
|
||||||
"expo-linear-gradient": "~15.0.7",
|
"expo-linear-gradient": "~15.0.7",
|
||||||
"expo-localization": "~17.0.7",
|
"expo-localization": "~17.0.7",
|
||||||
|
"expo-navigation-bar": "~5.0.10",
|
||||||
"expo-notifications": "~0.32.12",
|
"expo-notifications": "~0.32.12",
|
||||||
"expo-random": "^14.0.1",
|
"expo-random": "^14.0.1",
|
||||||
"expo-screen-orientation": "~9.0.7",
|
"expo-screen-orientation": "~9.0.7",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
|
import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
|
||||||
import { View, StyleSheet, Platform, Animated } from 'react-native';
|
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
|
|
@ -349,8 +350,35 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
if (modals.showEpisodeStreamsModal) return;
|
if (modals.showEpisodeStreamsModal) return;
|
||||||
playerState.setPaused(true);
|
playerState.setPaused(true);
|
||||||
}}
|
}}
|
||||||
onError={(err) => {
|
onError={(err: any) => {
|
||||||
logger.error('Video Error', err);
|
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.setErrorDetails(JSON.stringify(err));
|
||||||
modals.setShowErrorModal(true);
|
modals.setShowErrorModal(true);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
117
src/components/player/android/MpvPlayer.tsx
Normal file
117
src/components/player/android/MpvPlayer.tsx
Normal file
|
|
@ -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<any>('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<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
|
||||||
|
const nativeRef = useRef<any>(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 (
|
||||||
|
<View style={[styles.container, props.style, { backgroundColor: 'black' }]} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<MpvPlayerNative
|
||||||
|
ref={nativeRef}
|
||||||
|
style={[styles.container, props.style]}
|
||||||
|
source={props.source}
|
||||||
|
paused={props.paused ?? true}
|
||||||
|
volume={props.volume ?? 1.0}
|
||||||
|
rate={props.rate ?? 1.0}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
onProgress={handleProgress}
|
||||||
|
onEnd={handleEnd}
|
||||||
|
onError={handleError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'black',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
MpvPlayer.displayName = 'MpvPlayer';
|
||||||
|
|
||||||
|
export default MpvPlayer;
|
||||||
|
|
@ -1,52 +1,18 @@
|
||||||
import React, { forwardRef } from 'react';
|
import React from 'react';
|
||||||
import { View, TouchableOpacity, StyleSheet, Platform } from 'react-native';
|
import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native';
|
||||||
import Video, { ViewType, VideoRef, ResizeMode } from 'react-native-video';
|
|
||||||
import VlcVideoPlayer, { VlcPlayerRef } from '../../VlcVideoPlayer';
|
|
||||||
import { PinchGestureHandler } from 'react-native-gesture-handler';
|
import { PinchGestureHandler } from 'react-native-gesture-handler';
|
||||||
|
import MpvPlayer, { MpvPlayerRef } from '../MpvPlayer';
|
||||||
import { styles } from '../../utils/playerStyles';
|
import { styles } from '../../utils/playerStyles';
|
||||||
|
import { ResizeModeType } from '../../utils/playerTypes';
|
||||||
import { logger } from '../../../../utils/logger';
|
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 {
|
interface VideoSurfaceProps {
|
||||||
useVLC: boolean;
|
|
||||||
forceVlcRemount: boolean;
|
|
||||||
processedStreamUrl: string;
|
processedStreamUrl: string;
|
||||||
volume: number;
|
volume: number;
|
||||||
playbackSpeed: number;
|
playbackSpeed: number;
|
||||||
zoomScale: number;
|
|
||||||
resizeMode: ResizeModeType;
|
resizeMode: ResizeModeType;
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
currentStreamUrl: string;
|
currentStreamUrl: string;
|
||||||
headers: any;
|
|
||||||
videoType: any;
|
|
||||||
vlcSelectedAudioTrack?: number;
|
|
||||||
vlcSelectedSubtitleTrack?: number;
|
|
||||||
vlcRestoreTime?: number;
|
|
||||||
vlcKey: string;
|
|
||||||
selectedAudioTrack: any;
|
|
||||||
selectedTextTrack: any;
|
|
||||||
useCustomSubtitles: boolean;
|
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
toggleControls: () => void;
|
toggleControls: () => void;
|
||||||
|
|
@ -56,44 +22,45 @@ interface VideoSurfaceProps {
|
||||||
onEnd: () => void;
|
onEnd: () => void;
|
||||||
onError: (err: any) => void;
|
onError: (err: any) => void;
|
||||||
onBuffer: (buf: any) => void;
|
onBuffer: (buf: any) => void;
|
||||||
onTracksUpdate: (tracks: any) => void;
|
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
vlcPlayerRef: React.RefObject<VlcPlayerRef>;
|
mpvPlayerRef?: React.RefObject<MpvPlayerRef>;
|
||||||
videoRef: React.RefObject<VideoRef>;
|
|
||||||
pinchRef: any;
|
pinchRef: any;
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
onPinchGestureEvent: any;
|
onPinchGestureEvent: any;
|
||||||
onPinchHandlerStateChange: any;
|
onPinchHandlerStateChange: any;
|
||||||
vlcLoadedRef: React.MutableRefObject<boolean>;
|
|
||||||
screenDimensions: { width: number, height: number };
|
screenDimensions: { width: number, height: number };
|
||||||
customVideoStyles: any;
|
|
||||||
|
|
||||||
// Debugging
|
// Legacy props (kept for compatibility but unused with MPV)
|
||||||
loadStartAtRef: React.MutableRefObject<number | null>;
|
useVLC?: boolean;
|
||||||
firstFrameAtRef: React.MutableRefObject<number | null>;
|
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<VideoSurfaceProps> = ({
|
export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
||||||
useVLC,
|
|
||||||
forceVlcRemount,
|
|
||||||
processedStreamUrl,
|
processedStreamUrl,
|
||||||
volume,
|
volume,
|
||||||
playbackSpeed,
|
playbackSpeed,
|
||||||
zoomScale,
|
|
||||||
resizeMode,
|
resizeMode,
|
||||||
paused,
|
paused,
|
||||||
currentStreamUrl,
|
currentStreamUrl,
|
||||||
headers,
|
|
||||||
videoType,
|
|
||||||
vlcSelectedAudioTrack,
|
|
||||||
vlcSelectedSubtitleTrack,
|
|
||||||
vlcRestoreTime,
|
|
||||||
vlcKey,
|
|
||||||
selectedAudioTrack,
|
|
||||||
selectedTextTrack,
|
|
||||||
useCustomSubtitles,
|
|
||||||
toggleControls,
|
toggleControls,
|
||||||
onLoad,
|
onLoad,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
|
@ -101,23 +68,57 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
||||||
onEnd,
|
onEnd,
|
||||||
onError,
|
onError,
|
||||||
onBuffer,
|
onBuffer,
|
||||||
onTracksUpdate,
|
mpvPlayerRef,
|
||||||
vlcPlayerRef,
|
|
||||||
videoRef,
|
|
||||||
pinchRef,
|
pinchRef,
|
||||||
onPinchGestureEvent,
|
onPinchGestureEvent,
|
||||||
onPinchHandlerStateChange,
|
onPinchHandlerStateChange,
|
||||||
vlcLoadedRef,
|
|
||||||
screenDimensions,
|
screenDimensions,
|
||||||
customVideoStyles,
|
|
||||||
loadStartAtRef,
|
|
||||||
firstFrameAtRef
|
|
||||||
}) => {
|
}) => {
|
||||||
|
// Use the actual stream URL
|
||||||
|
const streamUrl = currentStreamUrl || processedStreamUrl;
|
||||||
|
|
||||||
const isHlsStream = (url: string) => {
|
console.log('[VideoSurface] Rendering with:', {
|
||||||
return url.includes('.m3u8') || url.includes('m3u8') ||
|
streamUrl: streamUrl?.substring(0, 50) + '...',
|
||||||
url.includes('hls') || url.includes('playlist') ||
|
paused,
|
||||||
(videoType && videoType.toLowerCase() === 'm3u8');
|
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 (
|
return (
|
||||||
|
|
@ -125,96 +126,53 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
||||||
width: screenDimensions.width,
|
width: screenDimensions.width,
|
||||||
height: screenDimensions.height,
|
height: screenDimensions.height,
|
||||||
}]}>
|
}]}>
|
||||||
|
{/* MPV Player - rendered at the bottom of the z-order */}
|
||||||
|
<MpvPlayer
|
||||||
|
ref={mpvPlayerRef}
|
||||||
|
source={streamUrl}
|
||||||
|
paused={paused}
|
||||||
|
volume={volume}
|
||||||
|
rate={playbackSpeed}
|
||||||
|
style={localStyles.player}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
onProgress={handleProgress}
|
||||||
|
onEnd={handleEnd}
|
||||||
|
onError={handleError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gesture overlay - transparent, on top of the player */}
|
||||||
<PinchGestureHandler
|
<PinchGestureHandler
|
||||||
ref={pinchRef}
|
ref={pinchRef}
|
||||||
onGestureEvent={onPinchGestureEvent}
|
onGestureEvent={onPinchGestureEvent}
|
||||||
onHandlerStateChange={onPinchHandlerStateChange}
|
onHandlerStateChange={onPinchHandlerStateChange}
|
||||||
>
|
>
|
||||||
<View style={{
|
<View style={localStyles.gestureOverlay} pointerEvents="box-only">
|
||||||
position: 'absolute',
|
<TouchableWithoutFeedback onPress={toggleControls}>
|
||||||
top: 0,
|
<View style={localStyles.touchArea} />
|
||||||
left: 0,
|
</TouchableWithoutFeedback>
|
||||||
width: screenDimensions.width,
|
|
||||||
height: screenDimensions.height,
|
|
||||||
}}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
activeOpacity={1}
|
|
||||||
onPress={toggleControls}
|
|
||||||
>
|
|
||||||
{useVLC && !forceVlcRemount ? (
|
|
||||||
<VlcVideoPlayer
|
|
||||||
ref={vlcPlayerRef}
|
|
||||||
source={processedStreamUrl}
|
|
||||||
volume={volume}
|
|
||||||
playbackSpeed={playbackSpeed}
|
|
||||||
zoomScale={zoomScale}
|
|
||||||
resizeMode={getVlcResizeMode(resizeMode)}
|
|
||||||
onLoad={(data) => {
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
style={[styles.video, customVideoStyles]}
|
|
||||||
source={{
|
|
||||||
uri: currentStreamUrl,
|
|
||||||
headers: headers,
|
|
||||||
type: isHlsStream(currentStreamUrl) ? 'm3u8' : videoType
|
|
||||||
}}
|
|
||||||
paused={paused}
|
|
||||||
onLoadStart={() => {
|
|
||||||
loadStartAtRef.current = Date.now();
|
|
||||||
}}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onLoad={onLoad}
|
|
||||||
onReadyForDisplay={() => {
|
|
||||||
firstFrameAtRef.current = Date.now();
|
|
||||||
}}
|
|
||||||
onSeek={onSeek}
|
|
||||||
onEnd={onEnd}
|
|
||||||
onError={onError}
|
|
||||||
onBuffer={onBuffer}
|
|
||||||
resizeMode={getVideoResizeMode(resizeMode)}
|
|
||||||
selectedAudioTrack={selectedAudioTrack || undefined}
|
|
||||||
selectedTextTrack={useCustomSubtitles ? { type: 'disabled' } as any : (selectedTextTrack >= 0 ? { type: 'index', value: selectedTextTrack } as any : undefined)}
|
|
||||||
rate={playbackSpeed}
|
|
||||||
volume={volume}
|
|
||||||
muted={false}
|
|
||||||
repeat={false}
|
|
||||||
playInBackground={false}
|
|
||||||
playWhenInactive={false}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
mixWithOthers="inherit"
|
|
||||||
progressUpdateInterval={500}
|
|
||||||
disableFocus={true}
|
|
||||||
allowsExternalPlayback={false}
|
|
||||||
preventsDisplaySleepDuringVideoPlayback={true}
|
|
||||||
viewType={Platform.OS === 'android' ? ViewType.SURFACE : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</PinchGestureHandler>
|
</PinchGestureHandler>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { StatusBar, Platform, Dimensions, AppState } from 'react-native';
|
import { StatusBar, Platform, Dimensions, AppState } from 'react-native';
|
||||||
import RNImmersiveMode from 'react-native-immersive-mode';
|
import RNImmersiveMode from 'react-native-immersive-mode';
|
||||||
|
import * as NavigationBar from 'expo-navigation-bar';
|
||||||
import * as Brightness from 'expo-brightness';
|
import * as Brightness from 'expo-brightness';
|
||||||
import { logger } from '../../../../utils/logger';
|
import { logger } from '../../../../utils/logger';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
|
@ -18,19 +19,34 @@ export const usePlayerSetup = (
|
||||||
const originalSystemBrightnessModeRef = useRef<number | null>(null);
|
const originalSystemBrightnessModeRef = useRef<number | null>(null);
|
||||||
const isAppBackgrounded = useRef(false);
|
const isAppBackgrounded = useRef(false);
|
||||||
|
|
||||||
const enableImmersiveMode = () => {
|
const enableImmersiveMode = async () => {
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
|
// Standard immersive mode
|
||||||
RNImmersiveMode.setBarTranslucent(true);
|
RNImmersiveMode.setBarTranslucent(true);
|
||||||
RNImmersiveMode.fullLayout(true);
|
RNImmersiveMode.fullLayout(true);
|
||||||
StatusBar.setHidden(true, 'none');
|
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') {
|
if (Platform.OS === 'android') {
|
||||||
RNImmersiveMode.setBarTranslucent(false);
|
RNImmersiveMode.setBarTranslucent(false);
|
||||||
RNImmersiveMode.fullLayout(false);
|
RNImmersiveMode.fullLayout(false);
|
||||||
StatusBar.setHidden(false, 'fade');
|
StatusBar.setHidden(false, 'fade');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await NavigationBar.setVisibilityAsync("visible");
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import * as ExpoClipboard from 'expo-clipboard';
|
||||||
import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions } from 'react-native';
|
import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions } from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
|
|
@ -21,6 +22,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
|
||||||
errorDetails,
|
errorDetails,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const MODAL_WIDTH = Math.min(width * 0.8, 400);
|
const MODAL_WIDTH = Math.min(width * 0.8, 400);
|
||||||
|
|
||||||
|
|
@ -31,6 +33,12 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
await ExpoClipboard.setStringAsync(errorDetails);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
if (!showErrorModal) return null;
|
if (!showErrorModal) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -74,16 +82,42 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
|
||||||
Playback Error
|
Playback Error
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text style={{
|
<Text
|
||||||
|
numberOfLines={3}
|
||||||
|
ellipsizeMode="tail"
|
||||||
|
style={{
|
||||||
color: 'rgba(255,255,255,0.7)',
|
color: 'rgba(255,255,255,0.7)',
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginBottom: 24,
|
marginBottom: 16,
|
||||||
lineHeight: 22
|
lineHeight: 22
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{errorDetails || 'An unknown error occurred during playback.'}
|
{errorDetails || 'An unknown error occurred during playback.'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleCopy}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 8,
|
||||||
|
marginBottom: 24,
|
||||||
|
opacity: 0.8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={copied ? "check" : "content-copy"}
|
||||||
|
size={16}
|
||||||
|
color="rgba(255,255,255,0.6)"
|
||||||
|
style={{ marginRight: 6 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 13, fontWeight: '500' }}>
|
||||||
|
{copied ? 'Copied to clipboard' : 'Copy error details'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'white',
|
backgroundColor: 'white',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue