diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/EglHdrCaps.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/EglHdrCaps.kt new file mode 100644 index 000000000..1869eb591 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/EglHdrCaps.kt @@ -0,0 +1,74 @@ +package com.edde746.plezy.mpv + +import android.opengl.EGL14 + +/** + * Which mpv `egl-output-format` this device can pair with a BT.2020 PQ window + * surface, or null when HDR GL output is unavailable. 10-bit fixed point is + * preferred; fp16 is the fallback because some drivers (Tegra among them) + * expose the PQ colorspace but no 1010102 window config. + * + * Both halves are required by the mpv side: the fork's android GL context asks + * for `EGL_EXT_gl_colorspace_bt2020_pq` on the window surface, and the app + * pairs it with an `egl-output-format` that makes mpv's EGL config selection + * fail outright when no matching config exists - so this probe must be + * consulted before those options are ever set. + */ +internal object EglHdrCaps { + private const val EGL_COLOR_COMPONENT_TYPE_EXT = 0x3339 + private const val EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT = 0x333B + + private object Unprobed + + @Volatile private var cached: Any? = Unprobed + + fun pqOutputFormat(): String? { + val value = cached + if (value !== Unprobed) return value as String? + return probe().also { cached = it } + } + + private fun probe(): String? { + val display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + if (display == EGL14.EGL_NO_DISPLAY) return null + val version = IntArray(2) + // Deliberately no eglTerminate: the default display is process-global and + // Flutter's renderer shares it; terminating would invalidate its state. + if (!EGL14.eglInitialize(display, version, 0, version, 1)) return null + val extensions = EGL14.eglQueryString(display, EGL14.EGL_EXTENSIONS) ?: return null + if (!extensions.contains("EGL_EXT_gl_colorspace_bt2020_pq")) return null + if (hasWindowConfig(display, intArrayOf(EGL14.EGL_RED_SIZE, 10, EGL14.EGL_GREEN_SIZE, 10, EGL14.EGL_BLUE_SIZE, 10, EGL14.EGL_ALPHA_SIZE, 2))) { + return "rgb10_a2" + } + if (extensions.contains("EGL_EXT_pixel_format_float") && + hasWindowConfig( + display, + intArrayOf( + EGL14.EGL_RED_SIZE, + 16, + EGL14.EGL_GREEN_SIZE, + 16, + EGL14.EGL_BLUE_SIZE, + 16, + EGL_COLOR_COMPONENT_TYPE_EXT, + EGL_COLOR_COMPONENT_TYPE_FLOAT_EXT + ) + ) + ) { + return "rgba16f" + } + return null + } + + private fun hasWindowConfig(display: android.opengl.EGLDisplay, extra: IntArray): Boolean { + val attribs = intArrayOf( + EGL14.EGL_SURFACE_TYPE, + EGL14.EGL_WINDOW_BIT, + EGL14.EGL_RENDERABLE_TYPE, + EGL14.EGL_OPENGL_ES2_BIT + ) + extra + intArrayOf(EGL14.EGL_NONE) + val numConfigs = IntArray(1) + if (!EGL14.eglChooseConfig(display, attribs, 0, null, 0, 0, numConfigs, 0)) return false + return numConfigs[0] > 0 + } +} 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 3bc3abd3f..df7f8780c 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 @@ -92,6 +92,14 @@ class MpvPlayerCore private constructor( * keeps the last duplicate key, so any user mpv.conf entries go first. */ internal fun mergeDecoderOptions(current: String?, ours: String): String = if (current.isNullOrBlank()) ours else "$current,$ours" + + /** + * Whether content with this transfer is worth an HDR (BT.2020 PQ) GL + * surface. PQ and HLG both render into a PQ target; everything else - + * including unknown - stays on the default sRGB surface, which renders + * every content correctly (HDR arrives tone-mapped, as before). + */ + internal fun wantsHdrSurface(transfer: String?): Boolean = transfer == "smpte2084" || transfer == "arib-std-b67" } /** Video-only paths. The plugin always constructs video cores with the @@ -125,6 +133,10 @@ class MpvPlayerCore private constructor( * routing policy. */ @Volatile private var currentDvConversionMode: String = "auto" + /** Whether this core already decided its GL surface colorspace; set by the + * first `content-color-transfer` announcement ([applyContentColorTransfer]). */ + @Volatile private var hdrSurfaceDecided: Boolean = false + @Volatile private var videoDisplayWidth: Int = 0 @Volatile private var videoDisplayHeight: Int = 0 @@ -325,6 +337,7 @@ class MpvPlayerCore private constructor( videoPanscan = 0f videoZoomLog2 = 0f currentDvConversionMode = "auto" + hdrSurfaceDecided = false if (!audioOnly) ensurePlaceholderSurface() // Initialize audio focus handling. mpv has none built in, so both modes @@ -1346,6 +1359,58 @@ class MpvPlayerCore private constructor( } } + /** + * `content-color-transfer` is an app-level property: Dart announces the + * selected stream's transfer (server metadata) before playback so an HDR + * session can get a BT.2020 PQ 10-bit GL surface instead of tone-mapped + * SDR. Consumed by whichever android GL context the session ever creates - + * up front for a software session, or at the fallback transition when a + * plane session leaves vo=mediacodec (the plane itself carries HDR via the + * decoder's dataspace and ignores all of this). + * + * The first announcement decides for the whole core: the surface colorspace + * is fixed at EGL-surface creation, and both latched states stay correct + * for later files (a PQ target renders SDR content correctly, an sRGB + * surface tone-maps HDR as before) - re-deciding mid-session could pair a + * live sRGB surface with a PQ render target, which is wrong everywhere. + */ + private fun applyContentColorTransfer(value: String, onComplete: ((Result) -> Unit)?) { + val transfer = value.trim().lowercase() + if (hdrSurfaceDecided) { + onComplete?.invoke(Result.success(Unit)) + return + } + hdrSurfaceDecided = true + val wants = wantsHdrSurface(transfer) + val displayHdr = wants && DoviBridge.displaySupportsHdr(context) + val outputFormat = if (wants) EglHdrCaps.pqOutputFormat() else null + if (!wants || !displayHdr || outputFormat == null) { + if (wants) { + Log.i(TAG, "HDR GL surface unavailable (transfer=$transfer displayHdr=$displayHdr eglFormat=$outputFormat)") + } + onComplete?.invoke(Result.success(Unit)) + return + } + Log.i(TAG, "HDR GL surface engaged: BT.2020 PQ / $outputFormat for transfer=$transfer") + scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) { + val completion = try { + writeProperty("android-surface-colorspace", "bt2020-pq") + writeProperty("egl-output-format", outputFormat) + writeProperty("target-trc", "pq") + writeProperty("target-prim", "bt.2020") + Result.success(Unit) + } catch (error: CancellationException) { + Result.failure(error) + } catch (error: Exception) { + Log.w(TAG, "HDR surface property write failed") + Result.failure(error) + } + withContext(NonCancellable + Dispatchers.Main) { + onComplete?.invoke(completion) + } + } + } + fun setProperty(name: String, value: String, onComplete: ((Result) -> Unit)? = null) { if (!isInitialized || disposing || !scope.isActive) { onComplete?.invoke(Result.failure(CancellationException("MPV core unavailable"))) @@ -1357,6 +1422,11 @@ class MpvPlayerCore private constructor( return } + if (name == "content-color-transfer") { + applyContentColorTransfer(value, onComplete) + return + } + // View geometry on the plane (see VideoRectPolicy), but both still fall // through to mpv, which is what makes them work unchanged on the GL vos. if (name == "panscan" || name == "video-zoom") { diff --git a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt index a6e32bf08..ec6d42cfa 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/mpv/MpvPlayerPluginTest.kt @@ -68,6 +68,20 @@ class MpvPlayerPluginTest { assertEquals("gpu,gpu-next", MpvPlayerCore.initialVideoOutput(hardwareDecoding = false)) } + @Test + fun hdrSurfaceIsWantedOnlyForPqAndHlgTransfers() { + // Rationale on MpvPlayerCore.wantsHdrSurface: both render into a PQ + // target; anything else stays on the sRGB surface, which renders every + // content correctly. + assertTrue(MpvPlayerCore.wantsHdrSurface("smpte2084")) + assertTrue(MpvPlayerCore.wantsHdrSurface("arib-std-b67")) + assertFalse(MpvPlayerCore.wantsHdrSurface("bt709")) + assertFalse(MpvPlayerCore.wantsHdrSurface("bt1886")) + assertFalse(MpvPlayerCore.wantsHdrSurface("unknown")) + assertFalse(MpvPlayerCore.wantsHdrSurface("")) + assertFalse(MpvPlayerCore.wantsHdrSurface(null)) + } + @Test fun setPropertyWithoutCoreReportsNotInitializedForVideoAndAudio() { for (plugin in listOf(MpvPlayerPlugin(), MpvAudioPlayerPlugin())) { diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 00464e6e2..ebd80ae95 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -133,12 +133,24 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { /// before the decoder emits stream properties. The native side resolves /// only after any resulting display-mode switch has settled, plus the /// user-configured extra delay on Apple TV. + /// + /// On Android mpv the same server metadata announces the stream's transfer + /// (`content-color-transfer`) so an HDR session can get a BT.2020 PQ GL + /// surface if it ever renders through GL (software fallback, hardware + /// decoding off). Transcoded streams stay unannounced: the server may + /// tone-map, so the default SDR surface is the safe target. Future _primeDisplayCriteria({ required Player player, required SettingsService settingsService, required MediaDisplayCriteria? displayCriteria, required bool isTranscoding, - }) { + }) async { + // needsDecoderRefreshAfterDisplaySwitch is how this file distinguishes + // the two Android backends (true = the mpv core). + if (Platform.isAndroid && player.needsDecoderRefreshAfterDisplaySwitch) { + final transfer = isTranscoding ? null : displayCriteria?.transfer; + await player.setProperty('content-color-transfer', transfer ?? 'unknown'); + } return player.setDisplayCriteria( !isTranscoding && displayCriteria?.canPrimeNativeDisplayCriteria == true ? displayCriteria : null, extraDelayMs: PlatformDetector.isAppleTV() ? settingsService.read(SettingsService.displaySwitchDelay) * 1000 : 0,