fix(player): render HDR letterbox bars off the SDR window plane

HDR/Dolby Vision playback on Fire TV (and some Sony/Philips models)
shows gray letterbox bars instead of black on OLED panels: the bars
were the player container's window-plane background, and those device
compositors raise SDR graphics-plane black while the display is in
HDR/DV output mode. Black inside the video plane is unaffected (same
mechanism as ExoPlayer #8803 and Kodi #25300).

Add a fullscreen, buffer-less SurfaceView beneath the video surface in
the shared player container. Like any below-window SurfaceView it
registers its rect as a window transparent region, so the letterbox
area scans out as the SurfaceFlinger backdrop instead of window-plane
pixels. No buffer is ever posted: SurfaceFlinger skips buffer-less
layers, and drawing into one would put the bars back on an SDR layer.
Covers both the ExoPlayer and mpv backends.

close #2163
This commit is contained in:
edde746
2026-08-28 11:24:37 +02:00
parent 861111a191
commit ff1064d35f
2 changed files with 53 additions and 0 deletions
@@ -15,8 +15,37 @@ internal object PlayerSurfaceHost {
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// Fallback fill for the frames before the punch surface below is placed.
setBackgroundColor(Color.BLACK)
this.clipChildren = clipChildren
addView(createLetterboxPunchSurface(activity))
}
/**
* Fullscreen, buffer-less SurfaceView kept beneath the video surface.
*
* Its only job is taking the letterbox area off the app-window (graphics)
* plane: like any below-window SurfaceView it punches the parent canvas and
* registers its rect as a window transparent region, so the pixels around
* the video rect scan out as the SurfaceFlinger backdrop instead of
* window-plane black. Several TV compositors (Fire TV Stick 4K Max, some
* Sony/Philips models) raise SDR graphics-plane black while the display is
* in HDR/Dolby Vision mode, which shows as gray letterbox bars on OLED
* panels (issue #2163; same mechanism as ExoPlayer #8803 and Kodi #25300).
*
* No buffer is ever posted to it: SurfaceFlinger skips buffer-less layers,
* and drawing black into it would put the bars back onto an SDR layer —
* exactly the plane being avoided. Views drawn on the parent canvas after
* the punch (Media3 subtitle cues) still re-claim their own bounds.
*/
private fun createLetterboxPunchSurface(activity: Activity): SurfaceView = SurfaceView(activity).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
fun createVideoSurface(activity: Activity, callback: SurfaceHolder.Callback): SurfaceView = SurfaceView(activity).apply {
@@ -4,6 +4,7 @@ import android.app.Activity
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.ViewGroup
import android.widget.FrameLayout
import org.junit.Assert.assertEquals
@@ -41,4 +42,27 @@ class PlayerSurfaceHostTest {
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.width)
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, surface.layoutParams.height)
}
@Test
fun letterboxAreaIsCoveredByABufferlessPunchSurfaceBeneathTheVideo() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
activity.setContentView(FrameLayout(activity))
val callback = object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = Unit
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit
override fun surfaceDestroyed(holder: SurfaceHolder) = Unit
}
val container = PlayerSurfaceHost.createContainer(activity)
val punch = container.getChildAt(0)
assertTrue(punch is SurfaceView)
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.width)
assertEquals(FrameLayout.LayoutParams.MATCH_PARENT, punch.layoutParams.height)
// Cores append their surfaces after createContainer, so video (and the mpv
// OSD plane) always land above the punch layer in drawing order.
val video = PlayerSurfaceHost.createVideoSurface(activity, callback)
container.addView(video)
assertTrue(container.indexOfChild(video) > container.indexOfChild(punch))
}
}