From 9f19082d479b36e7edf39d273d71574b646d0d32 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:03:29 +0200 Subject: [PATCH] fix(android): hole-punch video and libass surfaces on API 36+ --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 22 +++++++++++-------- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 6 +++-- .../plezy/shared/FlutterOverlayHelper.kt | 21 ++++++++++++++---- 3 files changed, 34 insertions(+), 15 deletions(-) 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 89c95fe6..2f80a53d 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 @@ -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(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. 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 a52b6c0a..6186f659 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 @@ -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 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 486326ff..6ac44513 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 @@ -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)