diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1f4346cd..52e31986 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -26,7 +26,7 @@ android { applicationId = "com.edde746.plezy" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 26 // Required by libmpv-android targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName @@ -62,3 +62,7 @@ android { flutter { source = "../.." } + +dependencies { + implementation("dev.jdtech.mpv:libmpv:0.5.1") +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt new file mode 100644 index 00000000..c74cb09c --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -0,0 +1,244 @@ +package com.edde746.plezy.mpv + +import android.app.Activity +import android.graphics.Color +import android.graphics.PixelFormat +import android.util.Log +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.View +import android.view.ViewGroup +import dev.jdtech.mpv.MPVLib + +interface MpvPlayerDelegate { + fun onPropertyChange(name: String, value: Any?) + fun onEvent(name: String, data: Map?) +} + +class MpvPlayerCore(private val activity: Activity) : + SurfaceHolder.Callback, + MPVLib.EventObserver { + + companion object { + private const val TAG = "MpvPlayerCore" + } + + private var surfaceView: SurfaceView? = null + var delegate: MpvPlayerDelegate? = null + var isInitialized: Boolean = false + private set + + fun initialize(): Boolean { + if (isInitialized) { + Log.d(TAG, "Already initialized") + return true + } + + try { + // Create SurfaceView for video rendering + surfaceView = SurfaceView(activity).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + setBackgroundColor(Color.BLACK) + holder.addCallback(this@MpvPlayerCore) + + // Critical: Ensure SurfaceView renders BEHIND Flutter's view + setZOrderOnTop(false) + setZOrderMediaOverlay(false) + } + + // Insert SurfaceView at bottom of view hierarchy (behind Flutter) + val contentView = activity.findViewById(android.R.id.content) + contentView.addView(surfaceView, 0) + + // Find FlutterView and its internal FlutterSurfaceView, set it on top + for (i in 0 until contentView.childCount) { + val child = contentView.getChildAt(i) + if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) { + // Look inside FlutterView for FlutterSurfaceView + for (j in 0 until child.childCount) { + val flutterChild = child.getChildAt(j) + if (flutterChild is SurfaceView) { + // Put Flutter in media overlay layer (above our video which is in normal layer) + flutterChild.setZOrderMediaOverlay(true) + flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT) + Log.d(TAG, "Set FlutterSurfaceView to MediaOverlay with TRANSLUCENT: ${flutterChild.javaClass.name}") + break + } + } + break + } + } + + Log.d(TAG, "SurfaceView added to content view") + + // Initialize MPVLib + MPVLib.create(activity.applicationContext) + + // Configure MPV defaults + setupMpvDefaults() + + // Initialize MPV + MPVLib.init() + + // Register event observer + MPVLib.addObserver(this) + + isInitialized = true + Log.d(TAG, "Initialized successfully") + return true + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize: ${e.message}", e) + return false + } + } + + private fun setupMpvDefaults() { + // Video output configuration + MPVLib.setOptionString("vo", "gpu") + MPVLib.setOptionString("gpu-context", "android") + MPVLib.setOptionString("hwdec", "mediacodec-copy") + + // Audio configuration + MPVLib.setOptionString("ao", "audiotrack") + } + + // SurfaceHolder.Callback + + override fun surfaceCreated(holder: SurfaceHolder) { + Log.d(TAG, "Surface created") + MPVLib.attachSurface(holder.surface) + MPVLib.setOptionString("force-window", "yes") + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + Log.d(TAG, "Surface changed: ${width}x${height}") + MPVLib.setPropertyString("android-surface-size", "${width}x${height}") + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + Log.d(TAG, "Surface destroyed") + MPVLib.setOptionString("force-window", "no") + MPVLib.detachSurface() + } + + // MPVLib.EventObserver + + override fun eventProperty(property: String) { + // No value provided + } + + override fun eventProperty(property: String, value: Long) { + activity.runOnUiThread { + delegate?.onPropertyChange(property, value) + } + } + + override fun eventProperty(property: String, value: Double) { + activity.runOnUiThread { + delegate?.onPropertyChange(property, value) + } + } + + override fun eventProperty(property: String, value: Boolean) { + activity.runOnUiThread { + delegate?.onPropertyChange(property, value) + } + } + + override fun eventProperty(property: String, value: String) { + activity.runOnUiThread { + delegate?.onPropertyChange(property, value) + } + } + + override fun event(eventId: Int) { + val eventName = when (eventId) { + MPVLib.MPV_EVENT_FILE_LOADED -> "file-loaded" + MPVLib.MPV_EVENT_END_FILE -> "end-file" + MPVLib.MPV_EVENT_PLAYBACK_RESTART -> "playback-restart" + else -> null + } + eventName?.let { name -> + activity.runOnUiThread { + delegate?.onEvent(name, null) + } + } + } + + // Public API + + fun setProperty(name: String, value: String) { + if (!isInitialized) return + MPVLib.setPropertyString(name, value) + } + + fun getProperty(name: String): String? { + if (!isInitialized) return null + return try { + MPVLib.getPropertyString(name) + } catch (e: Exception) { + null + } + } + + fun observeProperty(name: String, format: String) { + if (!isInitialized) return + + val mpvFormat = when (format) { + "double" -> MPVLib.MPV_FORMAT_DOUBLE + "flag" -> MPVLib.MPV_FORMAT_FLAG + "string" -> MPVLib.MPV_FORMAT_STRING + "node" -> MPVLib.MPV_FORMAT_NODE + else -> MPVLib.MPV_FORMAT_NONE + } + MPVLib.observeProperty(name, mpvFormat) + } + + fun command(args: Array) { + if (!isInitialized || args.isEmpty()) return + MPVLib.command(args) + } + + fun setVisible(visible: Boolean) { + activity.runOnUiThread { + surfaceView?.visibility = if (visible) View.VISIBLE else View.INVISIBLE + Log.d(TAG, "setVisible($visible)") + } + } + + // Lifecycle handling + + fun onPause() { + if (!isInitialized) return + Log.d(TAG, "onPause - disabling video") + setProperty("vid", "no") + } + + fun onResume() { + if (!isInitialized) return + Log.d(TAG, "onResume - enabling video") + setProperty("vid", "auto") + } + + // Cleanup + + fun dispose() { + Log.d(TAG, "Disposing") + + MPVLib.removeObserver(this) + + surfaceView?.holder?.removeCallback(this) + + val contentView = activity.findViewById(android.R.id.content) + surfaceView?.let { contentView.removeView(it) } + surfaceView = null + + MPVLib.destroy() + isInitialized = false + + Log.d(TAG, "Disposed") + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt new file mode 100644 index 00000000..fbaa49d8 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -0,0 +1,226 @@ +package com.edde746.plezy.mpv + +import android.app.Activity +import android.util.Log +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, + EventChannel.StreamHandler, ActivityAware, MpvPlayerDelegate { + + companion object { + private const val TAG = "MpvPlayerPlugin" + private const val METHOD_CHANNEL = "com.plezy/mpv_player" + private const val EVENT_CHANNEL = "com.plezy/mpv_player/events" + } + + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private var eventSink: EventChannel.EventSink? = null + private var playerCore: MpvPlayerCore? = null + private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null + + // FlutterPlugin + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) + methodChannel.setMethodCallHandler(this) + + eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) + eventChannel.setStreamHandler(this) + + Log.d(TAG, "Attached to engine") + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + Log.d(TAG, "Detached from engine") + } + + // ActivityAware + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + activityBinding = binding + binding.addOnUserLeaveHintListener { playerCore?.onPause() } + Log.d(TAG, "Attached to activity") + } + + override fun onDetachedFromActivity() { + playerCore?.dispose() + playerCore = null + activity = null + activityBinding = null + Log.d(TAG, "Detached from activity") + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + activityBinding = binding + Log.d(TAG, "Reattached to activity for config changes") + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + activityBinding = null + Log.d(TAG, "Detached from activity for config changes") + } + + // EventChannel.StreamHandler + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + Log.d(TAG, "Event stream connected") + } + + override fun onCancel(arguments: Any?) { + eventSink = null + Log.d(TAG, "Event stream disconnected") + } + + // MethodChannel.MethodCallHandler + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "initialize" -> handleInitialize(result) + "dispose" -> handleDispose(result) + "setProperty" -> handleSetProperty(call, result) + "getProperty" -> handleGetProperty(call, result) + "observeProperty" -> handleObserveProperty(call, result) + "command" -> handleCommand(call, result) + "setVisible" -> handleSetVisible(call, result) + "isInitialized" -> result.success(playerCore?.isInitialized ?: false) + else -> result.notImplemented() + } + } + + private fun handleInitialize(result: MethodChannel.Result) { + val currentActivity = activity + if (currentActivity == null) { + result.error("NO_ACTIVITY", "Activity not available", null) + return + } + + if (playerCore?.isInitialized == true) { + Log.d(TAG, "Already initialized") + result.success(true) + return + } + + currentActivity.runOnUiThread { + try { + playerCore = MpvPlayerCore(currentActivity).apply { + delegate = this@MpvPlayerPlugin + } + val success = playerCore?.initialize() ?: false + + // Start hidden + playerCore?.setVisible(false) + + Log.d(TAG, "Initialized: $success") + result.success(success) + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize: ${e.message}", e) + result.error("INIT_FAILED", e.message, null) + } + } + } + + private fun handleDispose(result: MethodChannel.Result) { + activity?.runOnUiThread { + playerCore?.dispose() + playerCore = null + Log.d(TAG, "Disposed") + result.success(null) + } ?: result.success(null) + } + + private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) { + val name = call.argument("name") + val value = call.argument("value") + + if (name == null || value == null) { + result.error("INVALID_ARGS", "Missing 'name' or 'value'", null) + return + } + + playerCore?.setProperty(name, value) + result.success(null) + } + + private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) { + val name = call.argument("name") + + if (name == null) { + result.error("INVALID_ARGS", "Missing 'name'", null) + return + } + + val value = playerCore?.getProperty(name) + result.success(value) + } + + private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) { + val name = call.argument("name") + val format = call.argument("format") + + if (name == null || format == null) { + result.error("INVALID_ARGS", "Missing 'name' or 'format'", null) + return + } + + playerCore?.observeProperty(name, format) + result.success(null) + } + + private fun handleCommand(call: MethodCall, result: MethodChannel.Result) { + val args = call.argument>("args") + + if (args == null) { + result.error("INVALID_ARGS", "Missing 'args'", null) + return + } + + playerCore?.command(args.toTypedArray()) + result.success(null) + } + + private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) { + val visible = call.argument("visible") + + if (visible == null) { + result.error("INVALID_ARGS", "Missing 'visible'", null) + return + } + + playerCore?.setVisible(visible) + result.success(null) + } + + // MpvPlayerDelegate + + override fun onPropertyChange(name: String, value: Any?) { + eventSink?.success( + mapOf( + "type" to "property", + "name" to name, + "value" to value + ) + ) + } + + override fun onEvent(name: String, data: Map?) { + val event = mutableMapOf( + "type" to "event", + "name" to name + ) + data?.let { event["data"] = it } + eventSink?.success(event) + } +} diff --git a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt index c545cdce..70a7b545 100644 --- a/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/flutter_application_1/MainActivity.kt @@ -1,5 +1,12 @@ package com.edde746.plezy import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import com.edde746.plezy.mpv.MpvPlayerPlugin -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.plugins.add(MpvPlayerPlugin()) + } +} diff --git a/lib/mpv/src/player/mpv_player.dart b/lib/mpv/src/player/mpv_player.dart index 37825d99..60b30385 100644 --- a/lib/mpv/src/player/mpv_player.dart +++ b/lib/mpv/src/player/mpv_player.dart @@ -4,6 +4,7 @@ import '../models/mpv_audio_device.dart'; import '../models/mpv_media.dart'; import '../models/mpv_audio_track.dart'; import '../models/mpv_subtitle_track.dart'; +import 'mpv_player_android.dart'; import 'mpv_player_ios.dart'; import 'mpv_player_macos.dart'; import 'mpv_player_state.dart'; @@ -197,7 +198,10 @@ abstract class MpvPlayer { if (Platform.isIOS) { return MpvPlayerIOS(); } - // Future: Add Android, Windows, Linux implementations + if (Platform.isAndroid) { + return MpvPlayerAndroid(); + } + // Future: Add Windows, Linux implementations return MpvPlayerStub(); } } diff --git a/lib/mpv/src/player/mpv_player_android.dart b/lib/mpv/src/player/mpv_player_android.dart new file mode 100644 index 00000000..9490ab46 --- /dev/null +++ b/lib/mpv/src/player/mpv_player_android.dart @@ -0,0 +1,5 @@ +import 'mpv_player_native.dart'; + +/// Android implementation of MpvPlayer. +/// Inherits all functionality from MpvPlayerNative. +class MpvPlayerAndroid extends MpvPlayerNative {}