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
import android.content.Intent
import android.graphics.SurfaceTexture
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.app.AppOpsManager
import android.app.PictureInPictureParams
import android.content.Context
@@ -14,14 +11,12 @@ import android.content.res.Configuration
import android.util.Log
import android.util.Rational
import android.view.KeyEvent
import android.view.TextureView
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import androidx.core.content.FileProvider
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterShellArgs
import io.flutter.embedding.android.FlutterTextureView
import io.flutter.embedding.android.RenderMode
import io.flutter.embedding.android.TransparencyMode
import io.flutter.embedding.engine.FlutterEngine
@@ -149,51 +144,13 @@ class MainActivity : FlutterActivity() {
return false
}
override fun getRenderMode(): RenderMode {
// 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 getRenderMode(): RenderMode = RenderMode.surface
override fun getTransparencyMode(): TransparencyMode {
// Keep Flutter transparent so video/subtitles are visible below.
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) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(MpvPlayerPlugin())
@@ -217,7 +217,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (disposing || !isInitialized) return@post
val container = FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)
?: 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.setZOrderMediaOverlay(true)
child.holder.setFormat(PixelFormat.TRANSLUCENT)
FlutterOverlayHelper.applyCompositionOrder(child, 1)
} else if (child is TextureView) {
child.isOpaque = false
}
@@ -302,6 +307,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
holder.addCallback(surfaceCallback)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, 0)
}
videoAspectContainer!!.addView(surfaceView)
@@ -325,9 +331,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
contentView.addView(surfaceContainer, 0)
// 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.configureFlutterZOrder(contentView, container, zOrderOnTop = false)
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 2)
}
ensureFlutterOverlayOnTop()
@@ -77,7 +77,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
flutterOverlayApplied = true
return@post
}
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = true)
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true
}
}
@@ -154,6 +154,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
holder.addCallback(this@MpvPlayerCore)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, 0)
}
// 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)
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.configureFlutterZOrder(contentView, container, zOrderOnTop = true)
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true
}
ensureFlutterOverlayOnTop()
@@ -1,6 +1,7 @@
package com.edde746.plezy.shared
import android.graphics.PixelFormat
import android.os.Build
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
@@ -9,51 +10,70 @@ import android.view.ViewGroup
object FlutterOverlayHelper {
/**
* Find the FlutterView container in the content view hierarchy.
* First tries to match by class name (works in debug builds),
* then falls back to picking the last ViewGroup with children
* (needed for release builds where FlutterView may be obfuscated).
* Find the top-level container holding the Flutter render surface. Returns the
* direct child of [contentView] that contains a FlutterSurfaceView/FlutterTextureView
* at any depth — `bringChildToFront` only works for direct children, so this is the
* 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? {
// 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) {
val child = contentView.getChildAt(i)
if (child is ViewGroup && child != excludeView && child.childCount > 0) {
return child
}
if (child === excludeView || child !is ViewGroup) continue
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
}
/**
* Configure z-ordering so the Flutter UI renders above the video surface.
*
* @param zOrderOnTop true for MPV (Flutter needs to be fully on top),
* false for ExoPlayer (subtitle layer sits between video and Flutter)
* Apply [SurfaceView.setCompositionOrder] on API 36+; no-op on older APIs where
* the legacy [SurfaceView.setZOrderOnTop]/[SurfaceView.setZOrderMediaOverlay]
* bucket settings govern Z-order instead.
*/
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)
for (j in 0 until container.childCount) {
val flutterChild = container.getChildAt(j)
if (flutterChild is SurfaceView) {
flutterChild.setZOrderOnTop(zOrderOnTop)
flutterChild.setZOrderMediaOverlay(true)
flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT)
break
} else if (flutterChild is TextureView) {
flutterChild.isOpaque = false
break
when (val surface = findRenderSurface(container)) {
is SurfaceView -> {
if (Build.VERSION.SDK_INT >= 36) {
// Clear legacy bucket hints so compositionOrder is authoritative.
// Flutter's FlutterSurfaceView sets setZOrderOnTop(true) in its
// transparent-mode constructor, which otherwise pins it to z=1.
surface.setZOrderOnTop(false)
surface.setZOrderMediaOverlay(false)
surface.compositionOrder = compositionOrder
} 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
}
}
}