From 39bcd9b9439ba25974424ebdb3751932ac1bf577 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:10:34 +0200 Subject: [PATCH] feat(android): flutter surfaceview rendering --- .../kotlin/com/edde746/plezy/MainActivity.kt | 45 +--------- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 13 ++- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 8 +- .../plezy/shared/FlutterOverlayHelper.kt | 84 ++++++++++++------- 4 files changed, 68 insertions(+), 82 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index 1ba23782..9acc2030 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -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()) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 0bdd82d3..d2b5b235 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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() 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 index 4518ea0d..a52b6c0a 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -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(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() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt index 9be9e46e..486326ff 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/FlutterOverlayHelper.kt @@ -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 } } }