diff --git a/app.json b/app.json index b606a16..0787269 100644 --- a/app.json +++ b/app.json @@ -97,7 +97,8 @@ "receiverAppId": "CC1AD845", "iosStartDiscoveryAfterFirstTapOnCastButton": true } - ] + ], + "./plugins/mpv-bridge/withMpvBridge" ], "updates": { "enabled": true, diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index c83f212..cae37b9 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -475,7 +475,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = "Nuvio"; + PRODUCT_NAME = Nuvio; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -506,8 +506,8 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app"; - PRODUCT_NAME = "Nuvio"; + PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; + PRODUCT_NAME = Nuvio; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index ed449bc..b0ab03c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -213,6 +213,8 @@ PODS: - ExpoModulesCore - ExpoBrightness (14.0.8): - ExpoModulesCore + - ExpoClipboard (8.0.8): + - ExpoModulesCore - ExpoCrypto (15.0.8): - ExpoModulesCore - ExpoDevice (8.0.10): @@ -2749,6 +2751,7 @@ DEPENDENCIES: - ExpoAsset (from `../node_modules/expo-asset/ios`) - ExpoBlur (from `../node_modules/expo-blur/ios`) - ExpoBrightness (from `../node_modules/expo-brightness/ios`) + - ExpoClipboard (from `../node_modules/expo-clipboard/ios`) - ExpoCrypto (from `../node_modules/expo-crypto/ios`) - ExpoDevice (from `../node_modules/expo-device/ios`) - ExpoDocumentPicker (from `../node_modules/expo-document-picker/ios`) @@ -2912,6 +2915,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-blur/ios" ExpoBrightness: :path: "../node_modules/expo-brightness/ios" + ExpoClipboard: + :path: "../node_modules/expo-clipboard/ios" ExpoCrypto: :path: "../node_modules/expo-crypto/ios" ExpoDevice: @@ -3171,6 +3176,7 @@ SPEC CHECKSUMS: ExpoAsset: 23a958e97d3d340919fe6774db35d563241e6c03 ExpoBlur: b90747a3f22a8b6ceffd9cb0dc41a4184efdc656 ExpoBrightness: 46c980463e8a54b9ce77f923c4bff0bb0c9526e0 + ExpoClipboard: b36b287d8356887844bb08ed5c84b5979bb4dd1e ExpoCrypto: b6105ebaa15d6b38a811e71e43b52cd934945322 ExpoDevice: 6327c3c200816795708885adf540d26ecab83d1a ExpoDocumentPicker: 7cd9e71a0f66fb19eb0a586d6f26eee1284692e0 diff --git a/plugins/mpv-bridge/android/mpv/MPVView.kt b/plugins/mpv-bridge/android/mpv/MPVView.kt new file mode 100644 index 0000000..6f5727e --- /dev/null +++ b/plugins/mpv-bridge/android/mpv/MPVView.kt @@ -0,0 +1,427 @@ +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 + private var httpHeaders: Map? = 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 + var onTracksChangedCallback: ((audioTracks: List>, subtitleTracks: List>) -> 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 -> + applyHttpHeaders() + 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") + + // Hardware decoding - use mediacodec-copy to allow subtitle overlay + // 'mediacodec-copy' copies frames to CPU memory which enables subtitle blending + MPVLib.setOptionString("hwdec", "auto") + MPVLib.setOptionString("hwdec-codecs", "all") + + // 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("cache", "yes") + MPVLib.setOptionString("cache-secs", "30") + + // Network options + MPVLib.setOptionString("network-timeout", "60") // 60 second timeout + + // 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 + + // 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("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("osc", "no") // Disable on screen controller + 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 + + // Force subtitle rendering + MPVLib.setOptionString("sid", "auto") // Auto-select subtitle track + + // 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/full", MPV_FORMAT_DOUBLE) // Use /full for complete HLS duration + 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) + + // Observe subtitle properties for debugging + MPVLib.observeProperty("sid", MPV_FORMAT_INT64) + MPVLib.observeProperty("sub-visibility", MPV_FORMAT_FLAG) + MPVLib.observeProperty("sub-text", 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) { + // Apply headers before loading the file + applyHttpHeaders() + loadFile(url) + } else { + pendingDataSource = url + } + } + + fun setHeaders(headers: Map?) { + httpHeaders = headers + Log.d(TAG, "Headers set: $headers") + } + + private fun applyHttpHeaders() { + httpHeaders?.let { headers -> + if (headers.isNotEmpty()) { + // Format headers for MPV: comma-separated "Key: Value" pairs + val headerList = headers.map { (key, value) -> "$key: $value" } + val headerString = headerList.joinToString(",") + Log.d(TAG, "Applying HTTP headers: $headerString") + MPVLib.setOptionString("http-header-fields", headerString) + } + } + } + + fun setPaused(paused: Boolean) { + isPaused = paused + if (isMpvInitialized) { + MPVLib.setPropertyBoolean("pause", paused) + } + } + + fun seekTo(positionSeconds: Double) { + Log.d(TAG, "seekTo called: positionSeconds=$positionSeconds, isMpvInitialized=$isMpvInitialized") + if (isMpvInitialized) { + Log.d(TAG, "Executing MPV seek command: seek $positionSeconds absolute") + 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) { + Log.d(TAG, "setSubtitleTrack called: trackId=$trackId, isMpvInitialized=$isMpvInitialized") + if (isMpvInitialized) { + if (trackId == -1) { + Log.d(TAG, "Disabling subtitles (sid=no)") + MPVLib.setPropertyString("sid", "no") + MPVLib.setPropertyString("sub-visibility", "no") + } else { + Log.d(TAG, "Setting subtitle track to: $trackId") + MPVLib.setPropertyInt("sid", trackId) + // Ensure subtitles are visible + MPVLib.setPropertyString("sub-visibility", "yes") + + // Debug: Verify the subtitle was set correctly + val currentSid = MPVLib.getPropertyInt("sid") + val subVisibility = MPVLib.getPropertyString("sub-visibility") + val subDelay = MPVLib.getPropertyDouble("sub-delay") + val subScale = MPVLib.getPropertyDouble("sub-scale") + Log.d(TAG, "After setting - sid=$currentSid, sub-visibility=$subVisibility, sub-delay=$subDelay, sub-scale=$subScale") + } + } + } + + fun setResizeMode(mode: String) { + Log.d(TAG, "setResizeMode called: mode=$mode, isMpvInitialized=$isMpvInitialized") + if (isMpvInitialized) { + when (mode) { + "contain" -> { + // Letterbox - show entire video with black bars + MPVLib.setPropertyDouble("panscan", 0.0) + MPVLib.setPropertyString("keepaspect", "yes") + } + "cover" -> { + // Fill/crop - zoom to fill, cropping edges + MPVLib.setPropertyDouble("panscan", 1.0) + MPVLib.setPropertyString("keepaspect", "yes") + } + "stretch" -> { + // Stretch - disable aspect ratio + MPVLib.setPropertyDouble("panscan", 0.0) + MPVLib.setPropertyString("keepaspect", "no") + } + else -> { + // Default to contain + MPVLib.setPropertyDouble("panscan", 0.0) + MPVLib.setPropertyString("keepaspect", "yes") + } + } + } + } + + // MPVLib.EventObserver implementation + + override fun eventProperty(property: String) { + Log.d(TAG, "Property changed: $property") + when (property) { + "track-list" -> { + // Parse track list and notify React Native + parseAndSendTracks() + } + } + } + + private fun parseAndSendTracks() { + try { + val trackCount = MPVLib.getPropertyInt("track-list/count") ?: 0 + Log.d(TAG, "Track count: $trackCount") + + val audioTracks = mutableListOf>() + val subtitleTracks = mutableListOf>() + + for (i in 0 until trackCount) { + val type = MPVLib.getPropertyString("track-list/$i/type") ?: continue + val id = MPVLib.getPropertyInt("track-list/$i/id") ?: continue + val title = MPVLib.getPropertyString("track-list/$i/title") ?: "" + val lang = MPVLib.getPropertyString("track-list/$i/lang") ?: "" + val codec = MPVLib.getPropertyString("track-list/$i/codec") ?: "" + + val trackName = when { + title.isNotEmpty() -> title + lang.isNotEmpty() -> lang.uppercase() + else -> "Track $id" + } + + val track = mapOf( + "id" to id, + "name" to trackName, + "language" to lang, + "codec" to codec + ) + + when (type) { + "audio" -> { + Log.d(TAG, "Found audio track: $track") + audioTracks.add(track) + } + "sub" -> { + Log.d(TAG, "Found subtitle track: $track") + subtitleTracks.add(track) + } + } + } + + Log.d(TAG, "Sending tracks - Audio: ${audioTracks.size}, Subtitles: ${subtitleTracks.size}") + onTracksChangedCallback?.invoke(audioTracks, subtitleTracks) + } catch (e: Exception) { + Log.e(TAG, "Error parsing tracks", e) + } + } + + 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/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0 + onProgressCallback?.invoke(value, duration) + } + "duration/full", "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 -> { + Log.d(TAG, "MPV_EVENT_END_FILE") + + // Heuristic: If duration is effectively 0 at end of file, it's a load error + val duration = MPVLib.getPropertyDouble("duration/full") ?: MPVLib.getPropertyDouble("duration") ?: 0.0 + val timePos = MPVLib.getPropertyDouble("time-pos") ?: 0.0 + val eofReached = MPVLib.getPropertyBoolean("eof-reached") ?: false + + Log.d(TAG, "End stats - Duration: $duration, Time: $timePos, EOF: $eofReached") + + if (duration < 1.0 && !eofReached) { + val customError = "Unable to play media. Source may be unreachable." + Log.e(TAG, "Playback error detected (heuristic): $customError") + onErrorCallback?.invoke(customError) + } else { + onEndCallback?.invoke() + } + } + } + } +} diff --git a/plugins/mpv-bridge/android/mpv/MpvPackage.kt b/plugins/mpv-bridge/android/mpv/MpvPackage.kt new file mode 100644 index 0000000..49c3dd2 --- /dev/null +++ b/plugins/mpv-bridge/android/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/plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt b/plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt new file mode 100644 index 0000000..27d4852 --- /dev/null +++ b/plugins/mpv-bridge/android/mpv/MpvPlayerViewManager.kt @@ -0,0 +1,183 @@ +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) + } + + view.onTracksChangedCallback = { audioTracks, subtitleTracks -> + val event = Arguments.createMap().apply { + val audioArray = Arguments.createArray() + audioTracks.forEach { track -> + val trackMap = Arguments.createMap().apply { + putInt("id", track["id"] as Int) + putString("name", track["name"] as String) + putString("language", track["language"] as String) + putString("codec", track["codec"] as String) + } + audioArray.pushMap(trackMap) + } + putArray("audioTracks", audioArray) + + val subtitleArray = Arguments.createArray() + subtitleTracks.forEach { track -> + val trackMap = Arguments.createMap().apply { + putInt("id", track["id"] as Int) + putString("name", track["name"] as String) + putString("language", track["language"] as String) + putString("codec", track["codec"] as String) + } + subtitleArray.pushMap(trackMap) + } + putArray("subtitleTracks", subtitleArray) + } + sendEvent(context, view.id, "onTracksChanged", 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"))) + .put("onTracksChanged", MapBuilder.of("phasedRegistrationNames", MapBuilder.of("bubbled", "onTracksChanged"))) + .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?) { + android.util.Log.d("MpvPlayerViewManager", "receiveCommand: $commandId, args: $args") + when (commandId) { + "seek" -> { + val position = args?.getDouble(0) + android.util.Log.d("MpvPlayerViewManager", "Seek command received: position=$position") + position?.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 + } + + @ReactProp(name = "resizeMode") + fun setResizeMode(view: MPVView, resizeMode: String?) { + view.setResizeMode(resizeMode ?: "contain") + } + + @ReactProp(name = "headers") + fun setHeaders(view: MPVView, headers: com.facebook.react.bridge.ReadableMap?) { + if (headers != null) { + val headerMap = mutableMapOf() + val iterator = headers.keySetIterator() + while (iterator.hasNextKey()) { + val key = iterator.nextKey() + headers.getString(key)?.let { value -> + headerMap[key] = value + } + } + view.setHeaders(headerMap) + } else { + view.setHeaders(null) + } + } +} diff --git a/plugins/mpv-bridge/withMpvBridge.js b/plugins/mpv-bridge/withMpvBridge.js new file mode 100644 index 0000000..c898c61 --- /dev/null +++ b/plugins/mpv-bridge/withMpvBridge.js @@ -0,0 +1,94 @@ +const { withDangerousMod, withMainApplication, withMainActivity } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +/** + * Copy MPV native files to android project + */ +function copyMpvFiles(projectRoot) { + const sourceDir = path.join(projectRoot, 'plugins', 'mpv-bridge', 'android', 'mpv'); + const destDir = path.join(projectRoot, 'android', 'app', 'src', 'main', 'java', 'com', 'nuvio', 'app', 'mpv'); + + // Create destination directory if it doesn't exist + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + // Copy all files from source to destination + if (fs.existsSync(sourceDir)) { + const files = fs.readdirSync(sourceDir); + files.forEach(file => { + const srcFile = path.join(sourceDir, file); + const destFile = path.join(destDir, file); + if (fs.statSync(srcFile).isFile()) { + fs.copyFileSync(srcFile, destFile); + console.log(`[mpv-bridge] Copied ${file} to android project`); + } + }); + } +} + +/** + * Modify MainApplication.kt to include MpvPackage + */ +function withMpvMainApplication(config) { + return withMainApplication(config, async (config) => { + let contents = config.modResults.contents; + + // Add import for MpvPackage + const mpvImport = 'import com.nuvio.app.mpv.MpvPackage'; + if (!contents.includes(mpvImport)) { + // Add import after the last import statement + const lastImportIndex = contents.lastIndexOf('import '); + const endOfLastImport = contents.indexOf('\n', lastImportIndex); + contents = contents.slice(0, endOfLastImport + 1) + mpvImport + '\n' + contents.slice(endOfLastImport + 1); + } + + // Add MpvPackage to the packages list + const packagesPattern = /override fun getPackages\(\): List \{[\s\S]*?return PackageList\(this\)\.packages\.apply \{/; + if (contents.match(packagesPattern) && !contents.includes('MpvPackage()')) { + contents = contents.replace( + packagesPattern, + (match) => match + '\n add(MpvPackage())' + ); + } + + config.modResults.contents = contents; + return config; + }); +} + +/** + * Modify MainActivity.kt to handle MPV lifecycle if needed + */ +function withMpvMainActivity(config) { + return withMainActivity(config, async (config) => { + // Currently no modifications needed for MainActivity + // But this hook is available for future enhancements + return config; + }); +} + +/** + * Main plugin function + */ +function withMpvBridge(config) { + // Copy native files during prebuild + config = withDangerousMod(config, [ + 'android', + async (config) => { + copyMpvFiles(config.modRequest.projectRoot); + return config; + }, + ]); + + // Modify MainApplication to register the package + config = withMpvMainApplication(config); + + // Modify MainActivity if needed + config = withMpvMainActivity(config); + + return config; +} + +module.exports = withMpvBridge;