fix(android): hole-punch video and libass surfaces on API 36+

This commit is contained in:
edde746
2026-04-30 16:04:16 +02:00
parent 5b9719b96f
commit 9f19082d47
3 changed files with 34 additions and 15 deletions
@@ -221,7 +221,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// 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)
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
}
}
@@ -234,7 +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)
FlutterOverlayHelper.applyCompositionOrder(child, -1)
} else if (child is TextureView) {
child.isOpaque = false
}
@@ -307,7 +307,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
holder.addCallback(surfaceCallback)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, 0)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
videoAspectContainer!!.addView(surfaceView)
@@ -330,11 +330,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(surfaceContainer, 0)
// Find FlutterView and configure z-order
// Stack (top → bottom): Flutter UI (compositionOrder=2) > subtitle overlay
// (1) > video (0). On pre-36 this falls back to legacy bucket ordering.
// Find FlutterView and configure z-order. compositionOrder maps directly to
// SurfaceView mSubLayer on API 36+: negative values are hole-punched behind
// the parent canvas, non-negative are composited above. Media3's
// CanvasSubtitleOutput renders SRT/VTT/SDH text on the parent canvas, so the
// video and libass surfaces must be negative for non-ASS subs to be visible.
// Stack (back → front): video (-2) → libass overlay (-1) → parent canvas
// (CanvasSubtitleOutput) → Flutter UI (+1). Pre-36 falls back to legacy buckets.
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 2)
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
}
ensureFlutterOverlayOnTop()
@@ -490,8 +494,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// Add ASS overlay view to SubtitleView for OVERLAY modes.
// We use AssSubtitleSurfaceView directly (not AssSubtitleView) so we get a
// SurfaceFlinger-layer-backed overlay that eglPresentationTimeANDROID can
// vsync-pin. Z-order: default video SurfaceView < this MediaOverlay-flagged
// SurfaceView < Flutter TextureView in the window.
// vsync-pin. Z-order: video SurfaceView (-2) < this MediaOverlay-flagged
// SurfaceView (-1) < parent canvas < Flutter SurfaceView (+1) in the window.
//
// Inserted at child index 0 so the SurfaceView's transparent punch runs BEFORE
// SubtitleView's built-in CanvasSubtitleOutput child renders non-ASS cues.
@@ -154,7 +154,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
holder.addCallback(this@MpvPlayerCore)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, 0)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
// Add SurfaceView to container
@@ -165,7 +165,9 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
contentView.addView(surfaceContainer, 0)
// Find FlutterView and set it on top of our video surface.
// Stack (top → bottom): Flutter UI (compositionOrder=1) > video (0).
// compositionOrder maps directly to SurfaceView mSubLayer on API 36+:
// negative is hole-punched behind the parent canvas, non-negative is above.
// Stack (back → front): video (-2, hole-punched) → parent canvas → Flutter UI (+1).
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true
@@ -40,6 +40,15 @@ object FlutterOverlayHelper {
* Apply [SurfaceView.setCompositionOrder] on API 36+; no-op on older APIs where
* the legacy [SurfaceView.setZOrderOnTop]/[SurfaceView.setZOrderMediaOverlay]
* bucket settings govern Z-order instead.
*
* The value maps directly to the SurfaceView's `mSubLayer`. Per AOSP semantics:
* `mSubLayer >= 0` (non-negative `order`) is composited above the parent window
* and skips the hole-punch in `draw()`; `mSubLayer < 0` (negative `order`) is
* behind the parent window and the SurfaceView is made visible by punching a
* transparent hole in the parent canvas. Valid range is `[-2, 2]`. Picking the
* right sign matters: any view that draws on the parent canvas (e.g. Media3's
* `CanvasSubtitleOutput` for SRT/VTT/SDH) is hidden by SurfaceViews with
* non-negative orders that sit on top of the same area.
*/
fun applyCompositionOrder(view: SurfaceView, order: Int) {
if (Build.VERSION.SDK_INT >= 36) view.compositionOrder = order
@@ -48,10 +57,14 @@ object FlutterOverlayHelper {
/**
* 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).
* On API 36+ the value is applied via [SurfaceView.setCompositionOrder] and maps
* directly to `mSubLayer` (see [applyCompositionOrder] for the sign semantics).
* 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).
*
* Pass a non-negative value (e.g. `1`) so Flutter renders above the parent canvas
* and lets the legacy transparent-mode `setZOrderOnTop(true)` semantics carry over.
*/
fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, compositionOrder: Int) {
contentView.bringChildToFront(container)