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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user