feat(android): flutter surfaceview rendering

This commit is contained in:
edde746
2026-04-23 10:10:34 +02:00
parent ca5d87143b
commit 39bcd9b943
4 changed files with 68 additions and 82 deletions
@@ -1,12 +1,9 @@
package com.edde746.plezy package com.edde746.plezy
import android.content.Intent import android.content.Intent
import android.graphics.SurfaceTexture
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.app.AppOpsManager import android.app.AppOpsManager
import android.app.PictureInPictureParams import android.app.PictureInPictureParams
import android.content.Context import android.content.Context
@@ -14,14 +11,12 @@ import android.content.res.Configuration
import android.util.Log import android.util.Log
import android.util.Rational import android.util.Rational
import android.view.KeyEvent import android.view.KeyEvent
import android.view.TextureView
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterShellArgs import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.embedding.android.FlutterTextureView
import io.flutter.embedding.android.RenderMode import io.flutter.embedding.android.RenderMode
import io.flutter.embedding.android.TransparencyMode import io.flutter.embedding.android.TransparencyMode
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@@ -149,51 +144,13 @@ class MainActivity : FlutterActivity() {
return false return false
} }
override fun getRenderMode(): RenderMode { override fun getRenderMode(): RenderMode = RenderMode.surface
// Use TextureView so Flutter doesn't occupy a SurfaceView layer.
// This allows the libass subtitle SurfaceView to sit between video and Flutter UI.
return RenderMode.texture
}
override fun getTransparencyMode(): TransparencyMode { override fun getTransparencyMode(): TransparencyMode {
// Keep Flutter transparent so video/subtitles are visible below. // Keep Flutter transparent so video/subtitles are visible below.
return TransparencyMode.transparent return TransparencyMode.transparent
} }
override fun onFlutterTextureViewCreated(flutterTextureView: FlutterTextureView) {
val original = flutterTextureView.surfaceTextureListener ?: return
val handler = Handler(Looper.getMainLooper())
var pendingResize: Runnable? = null
var lastWidth = 0
var lastHeight = 0
flutterTextureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, w: Int, h: Int) {
original.onSurfaceTextureAvailable(surface, w, h)
}
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, w: Int, h: Int) {
if (w == lastWidth && h == lastHeight) return
lastWidth = w; lastHeight = h
pendingResize?.let { handler.removeCallbacks(it) }
pendingResize = Runnable {
if (flutterTextureView.isAvailable) {
original.onSurfaceTextureSizeChanged(surface, w, h)
}
}
handler.postDelayed(pendingResize!!, 100)
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
original.onSurfaceTextureUpdated(surface)
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
pendingResize?.let { handler.removeCallbacks(it) }
pendingResize = null
lastWidth = 0; lastHeight = 0
return original.onSurfaceTextureDestroyed(surface)
}
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(MpvPlayerPlugin()) flutterEngine.plugins.add(MpvPlayerPlugin())
@@ -217,7 +217,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (disposing || !isInitialized) return@post if (disposing || !isInitialized) return@post
val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer) val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)
?: return@post ?: return@post
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = false) // Fires on every layout pass via OnGlobalLayoutListener; skip when the
// container is already at the front to avoid recursing the view tree
// and re-writing compositionOrder each time.
if (contentView.getChildAt(contentView.childCount - 1) === container) return@post
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 2)
} }
} }
@@ -230,6 +234,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
child.setZOrderOnTop(false) child.setZOrderOnTop(false)
child.setZOrderMediaOverlay(true) child.setZOrderMediaOverlay(true)
child.holder.setFormat(PixelFormat.TRANSLUCENT) child.holder.setFormat(PixelFormat.TRANSLUCENT)
FlutterOverlayHelper.applyCompositionOrder(child, 1)
} else if (child is TextureView) { } else if (child is TextureView) {
child.isOpaque = false child.isOpaque = false
} }
@@ -302,6 +307,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
holder.addCallback(surfaceCallback) holder.addCallback(surfaceCallback)
setZOrderOnTop(false) setZOrderOnTop(false)
setZOrderMediaOverlay(false) setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, 0)
} }
videoAspectContainer!!.addView(surfaceView) videoAspectContainer!!.addView(surfaceView)
@@ -325,9 +331,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
contentView.addView(surfaceContainer, 0) contentView.addView(surfaceContainer, 0)
// Find FlutterView and configure z-order // Find FlutterView and configure z-order
// Video SurfaceView is at the bottom, Flutter uses setZOrderMediaOverlay to render above // Stack (top → bottom): Flutter UI (compositionOrder=2) > subtitle overlay
// (1) > video (0). On pre-36 this falls back to legacy bucket ordering.
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container -> FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = false) FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 2)
} }
ensureFlutterOverlayOnTop() ensureFlutterOverlayOnTop()
@@ -77,7 +77,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
flutterOverlayApplied = true flutterOverlayApplied = true
return@post return@post
} }
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = true) FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true flutterOverlayApplied = true
} }
} }
@@ -154,6 +154,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
holder.addCallback(this@MpvPlayerCore) holder.addCallback(this@MpvPlayerCore)
setZOrderOnTop(false) setZOrderOnTop(false)
setZOrderMediaOverlay(false) setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, 0)
} }
// Add SurfaceView to container // Add SurfaceView to container
@@ -163,9 +164,10 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
val contentView = activity.findViewById<ViewGroup>(android.R.id.content) val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(surfaceContainer, 0) contentView.addView(surfaceContainer, 0)
// Find FlutterView and set it on top of our video surface // Find FlutterView and set it on top of our video surface.
// Stack (top → bottom): Flutter UI (compositionOrder=1) > video (0).
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container -> FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = true) FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true flutterOverlayApplied = true
} }
ensureFlutterOverlayOnTop() ensureFlutterOverlayOnTop()
@@ -1,6 +1,7 @@
package com.edde746.plezy.shared package com.edde746.plezy.shared
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.os.Build
import android.view.SurfaceView import android.view.SurfaceView
import android.view.TextureView import android.view.TextureView
import android.view.View import android.view.View
@@ -9,51 +10,70 @@ import android.view.ViewGroup
object FlutterOverlayHelper { object FlutterOverlayHelper {
/** /**
* Find the FlutterView container in the content view hierarchy. * Find the top-level container holding the Flutter render surface. Returns the
* First tries to match by class name (works in debug builds), * direct child of [contentView] that contains a FlutterSurfaceView/FlutterTextureView
* then falls back to picking the last ViewGroup with children * at any depth — `bringChildToFront` only works for direct children, so this is the
* (needed for release builds where FlutterView may be obfuscated). * node to pass to [configureFlutterZOrder].
*
* Searches any depth because an app may wrap Flutter (e.g. for key-event dispatch)
* and push it below the direct-child level.
*/ */
fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? { fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? {
// First pass: look for FlutterView by name (debug builds)
for (i in 0 until contentView.childCount) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) {
return child
}
}
// Fallback for release (FlutterView may be obfuscated): pick the last ViewGroup
// that is not our video container and has children.
for (i in contentView.childCount - 1 downTo 0) { for (i in contentView.childCount - 1 downTo 0) {
val child = contentView.getChildAt(i) val child = contentView.getChildAt(i)
if (child is ViewGroup && child != excludeView && child.childCount > 0) { if (child === excludeView || child !is ViewGroup) continue
return child if (findRenderSurface(child) != null) return child
}
} }
return null
}
private fun findRenderSurface(root: ViewGroup): View? {
for (i in 0 until root.childCount) {
val child = root.getChildAt(i)
if (child is SurfaceView || child is TextureView) return child
if (child is ViewGroup) findRenderSurface(child)?.let { return it }
}
return null return null
} }
/** /**
* Configure z-ordering so the Flutter UI renders above the video surface. * Apply [SurfaceView.setCompositionOrder] on API 36+; no-op on older APIs where
* * the legacy [SurfaceView.setZOrderOnTop]/[SurfaceView.setZOrderMediaOverlay]
* @param zOrderOnTop true for MPV (Flutter needs to be fully on top), * bucket settings govern Z-order instead.
* false for ExoPlayer (subtitle layer sits between video and Flutter)
*/ */
fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, zOrderOnTop: Boolean) { fun applyCompositionOrder(view: SurfaceView, order: Int) {
if (Build.VERSION.SDK_INT >= 36) view.compositionOrder = order
}
/**
* Configure z-ordering so the Flutter UI renders above the video/subtitle surfaces.
*
* On API 36+ the value is applied via [SurfaceView.setCompositionOrder]: higher values
* render above peers with lower values. On pre-36 SurfaceView builds the value is
* mapped to the legacy on-top bucket when positive. On TextureView builds the value
* is unused (view hierarchy order handles it).
*/
fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, compositionOrder: Int) {
contentView.bringChildToFront(container) contentView.bringChildToFront(container)
for (j in 0 until container.childCount) { when (val surface = findRenderSurface(container)) {
val flutterChild = container.getChildAt(j) is SurfaceView -> {
if (flutterChild is SurfaceView) { if (Build.VERSION.SDK_INT >= 36) {
flutterChild.setZOrderOnTop(zOrderOnTop) // Clear legacy bucket hints so compositionOrder is authoritative.
flutterChild.setZOrderMediaOverlay(true) // Flutter's FlutterSurfaceView sets setZOrderOnTop(true) in its
flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT) // transparent-mode constructor, which otherwise pins it to z=1.
break surface.setZOrderOnTop(false)
} else if (flutterChild is TextureView) { surface.setZOrderMediaOverlay(false)
flutterChild.isOpaque = false surface.compositionOrder = compositionOrder
break } else {
// Pre-36 has 3 coarse sublayer buckets. Put Flutter in the on-top
// bucket so it renders above the video (default) and libass subtitle
// (media overlay) SurfaceViews. NB: setZOrderMediaOverlay overwrites
// mSubLayer internally, so don't call it here or it cancels setZOrderOnTop.
surface.setZOrderOnTop(compositionOrder > 0)
}
surface.holder.setFormat(PixelFormat.TRANSLUCENT)
} }
is TextureView -> surface.isOpaque = false
} }
} }
} }