This commit is contained in:
edde746
2025-12-02 15:46:19 +01:00
parent 1daded6ec6
commit e0fd77d7de
6 changed files with 493 additions and 3 deletions
+5 -1
View File
@@ -26,7 +26,7 @@ android {
applicationId = "com.edde746.plezy" applicationId = "com.edde746.plezy"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = 26 // Required by libmpv-android
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
@@ -62,3 +62,7 @@ android {
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies {
implementation("dev.jdtech.mpv:libmpv:0.5.1")
}
@@ -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<String, Any>?)
}
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<ViewGroup>(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<String>) {
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<ViewGroup>(android.R.id.content)
surfaceView?.let { contentView.removeView(it) }
surfaceView = null
MPVLib.destroy()
isInitialized = false
Log.d(TAG, "Disposed")
}
}
@@ -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<String>("name")
val value = call.argument<String>("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<String>("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<String>("name")
val format = call.argument<String>("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<List<String>>("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<Boolean>("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<String, Any>?) {
val event = mutableMapOf<String, Any>(
"type" to "event",
"name" to name
)
data?.let { event["data"] = it }
eventSink?.success(event)
}
}
@@ -1,5 +1,12 @@
package com.edde746.plezy package com.edde746.plezy
import io.flutter.embedding.android.FlutterActivity 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())
}
}
+5 -1
View File
@@ -4,6 +4,7 @@ import '../models/mpv_audio_device.dart';
import '../models/mpv_media.dart'; import '../models/mpv_media.dart';
import '../models/mpv_audio_track.dart'; import '../models/mpv_audio_track.dart';
import '../models/mpv_subtitle_track.dart'; import '../models/mpv_subtitle_track.dart';
import 'mpv_player_android.dart';
import 'mpv_player_ios.dart'; import 'mpv_player_ios.dart';
import 'mpv_player_macos.dart'; import 'mpv_player_macos.dart';
import 'mpv_player_state.dart'; import 'mpv_player_state.dart';
@@ -197,7 +198,10 @@ abstract class MpvPlayer {
if (Platform.isIOS) { if (Platform.isIOS) {
return MpvPlayerIOS(); return MpvPlayerIOS();
} }
// Future: Add Android, Windows, Linux implementations if (Platform.isAndroid) {
return MpvPlayerAndroid();
}
// Future: Add Windows, Linux implementations
return MpvPlayerStub(); return MpvPlayerStub();
} }
} }
@@ -0,0 +1,5 @@
import 'mpv_player_native.dart';
/// Android implementation of MpvPlayer.
/// Inherits all functionality from MpvPlayerNative.
class MpvPlayerAndroid extends MpvPlayerNative {}