diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/GpuVoPolicy.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/GpuVoPolicy.kt index de8918ee2..c76fc7d8a 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/GpuVoPolicy.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/GpuVoPolicy.kt @@ -35,6 +35,55 @@ internal object GpuVoPolicy { */ fun needsSoftwareRender(hwdecCurrent: String?): Boolean = !hwdecCurrent.isNullOrBlank() && hwdecCurrent != "mediacodec" + /** + * Whether a video track must be software-decoded up front because the + * bitstream is H.264 High 10 and no hardware decoder advertises the + * profile (#2065). Without this the session still ends up in software — + * MediaCodec refuses the stream, FFmpeg falls back, the 10-bit frames + * cannot enter the video plane and the chain fails — but only after a + * decoder init, a failed video chain and a vo recreation, several seconds + * of black with audio already running. [codec] and [codecProfile] come + * from mpv's track-list; the profile string is FFmpeg's + * (`avcodec_profile_name`: "High 10", "High 10 Intra"). + */ + fun needsSoftwareDecode(codec: String?, codecProfile: String?, hardwareHigh10: Boolean): Boolean = !hardwareHigh10 && codec == "h264" && codecProfile?.startsWith("High 10") == true + + /** + * Whether a GL vo session should drop to the cheap render tier: bilinear + * scalers and no dither. Keyed on `GL_EXT_texture_norm16` being absent, + * which on Android singles out the low-end Mali/Adreno TV class whose + * texture units cannot afford a second full-resolution pass at 1080p + * (measured on an S905X4/Mali-G31: mpv's default lanczos chroma pass alone + * runs the frame over budget, bilinear brings it back under; every + * norm16-capable GPU tested renders the whole default ladder in a few + * milliseconds). The video plane never scales in GL, so hardware sessions + * on it are untouched. + */ + fun needsCheapRenderTier(glVoActive: Boolean, textureNorm16: Boolean): Boolean = glVoActive && !textureNorm16 + + /** mpv option -> value for the cheap render tier, applied only where the + * option still carries its mpv default (a user's mpv.conf line wins). */ + val CHEAP_RENDER_OPTIONS: Map = linkedMapOf( + "scale" to "bilinear", + "cscale" to "bilinear", + "dscale" to "bilinear", + "dither" to "no" + ) + + /** The values mpv 0.41 reports for [CHEAP_RENDER_OPTIONS] when nothing + * set them; anything else is a user choice and stays. `cscale` inherits + * `scale` by default, which the property reads back as an empty string + * (measured on 0.41) or `inherit`. */ + val MPV_DEFAULT_RENDER_OPTIONS: Map> = mapOf( + "scale" to setOf("lanczos"), + "cscale" to setOf("", "inherit"), + "dscale" to setOf("hermite"), + "dither" to setOf("fruit") + ) + + /** Whether [value] is what mpv reports for [option] by default. */ + fun isDefaultRenderOption(option: String, value: String?): Boolean = value != null && MPV_DEFAULT_RENDER_OPTIONS[option]?.contains(value) == true + /** * The vo a session with these active requirements should run, or null for * the video plane. @@ -57,4 +106,5 @@ internal object GpuVoPolicy { const val REASON_CHAIN_FAILURE = "chain-failure" const val REASON_HDR_SDR = "hdr-sdr" const val REASON_SW_DECODE = "sw-decode" + const val REASON_HI10_SW_DECODE = "hi10-sw-decode" } 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 35b30c902..3296fd107 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 @@ -20,6 +20,8 @@ import com.edde746.plezy.exoplayer.DoviBridge import com.edde746.plezy.libmpv.* import com.edde746.plezy.shared.AudioFocusManager import com.edde746.plezy.shared.FrameRateManager +import com.edde746.plezy.shared.GlCapabilities +import com.edde746.plezy.shared.MediaCodecQuery import com.edde746.plezy.shared.PlayerDelegate import com.edde746.plezy.shared.PlayerSurfaceHost import com.edde746.plezy.shared.SurfacePlayerCore @@ -123,11 +125,20 @@ class MpvPlayerCore private constructor( * chain-failure watchdog. Written under [gpuVoReasons]. */ @Volatile private var activeGpuVoTarget: String? = null - /** Whether the per-file DV policy is holding hwdec at `no`; the session's - * own hwdec value is parked in [hwdecBeforeDvReshape] meanwhile. */ - @Volatile private var dvReshapeActive: Boolean = false + /** Per-file reasons holding hwdec at `no` (DV P5 reshaping, Hi10 without + * a hardware profile); the session's own hwdec value is parked in + * [parkedHwdec] while any is active. Written under itself. */ + private val hwdecHoldReasons = LinkedHashSet() - private val hwdecBeforeDvReshape = java.util.concurrent.atomic.AtomicReference() + @Volatile private var hwdecHeld: Boolean = false + + private val parkedHwdec = java.util.concurrent.atomic.AtomicReference() + + /** mpv option -> the default it carried before the cheap render tier + * replaced it; empty while the tier is off. See [applyRenderTier]. */ + private val cheapRenderRestore = LinkedHashMap() + + @Volatile private var cheapRenderTierActive: Boolean = false /** Last `dv-conversion-mode` Dart applied; input to the per-file DV * routing policy. */ @@ -335,8 +346,15 @@ class MpvPlayerCore private constructor( gpuVoReasons.clear() activeGpuVoTarget = null } - hwdecBeforeDvReshape.set(null) - dvReshapeActive = false + synchronized(hwdecHoldReasons) { + hwdecHoldReasons.clear() + hwdecHeld = false + } + parkedHwdec.set(null) + synchronized(cheapRenderRestore) { + cheapRenderRestore.clear() + cheapRenderTierActive = false + } attachedOsdSurface = null videoDisplayWidth = 0 videoDisplayHeight = 0 @@ -470,8 +488,35 @@ class MpvPlayerCore private constructor( player = p isInitialized = true + if (usesMediaCodecVo) { + // Per-file decode routing runs inside mpv's on_preloaded hook: + // the demuxer has opened the file, no decoder exists yet, and + // mpv waits for the answer. file-loaded would be too late — the + // MediaCodec decoder is already created by then (#2065). + p.hookHandler = { name -> + if (name == "on_preloaded" && !disposing) { + withContext(mpvWriteDispatcher) { + applyDvReshapePolicy(p) + applySoftwareDecodePolicy(p) + } + } + } + } if (!audioOnly) refreshVideoOutput("initialize") + if (!usesMediaCodecVo && !audioOnly) { + // vo=gpu from the start (hardware decoding off): same tier + // decision the plane sessions make when they leave the plane. + scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) { + try { + applyRenderTier(p, glVoActive = true) + } catch (e: CancellationException) { + Log.d(TAG, "Canceled render tier setup") + } catch (e: Exception) { + Log.w(TAG, "Render tier setup failed", e) + } + } + } // Start collecting events/properties/logs collectEvents(p) @@ -538,17 +583,6 @@ class MpvPlayerCore private constructor( delegate?.onEvent("start-file", lifecycleData(event.sourceId)) } is MpvEvent.FileLoaded -> { - if (usesMediaCodecVo) { - scope.launch(mpvWriteDispatcher, start = CoroutineStart.ATOMIC) { - try { - applyDvReshapePolicy(p) - } catch (e: CancellationException) { - Log.d(TAG, "Canceled DV routing policy") - } catch (e: Exception) { - Log.w(TAG, "DV routing policy failed", e) - } - } - } delegate?.onEvent("file-loaded", lifecycleData(event.sourceId)) } is MpvEvent.PlaybackRestart -> { @@ -775,6 +809,7 @@ class MpvPlayerCore private constructor( if (target == null && p != null) attachOsdSurfaceIfNeeded(p) writeProperty("vo", target ?: "mediacodec") if (p == null) return@launch + applyRenderTier(p, glVoActive = target != null) if (target != null) { // A failed conversion chain makes mpv deselect the video track // ("Could not initialize video chain" -> vid=no) before the VO @@ -817,20 +852,101 @@ class MpvPlayerCore private constructor( conversionMode = currentDvConversionMode, canPlayP5Natively = DoviBridge.canPlayDolbyVisionP5(context) ) - if (needs == dvReshapeActive) return - dvReshapeActive = needs - if (needs) { + if (holdHwdec(p, GpuVoPolicy.REASON_DV_RESHAPE, needs) && needs) { Log.i(TAG, "DV P5 (bitstream) without native support: software decode + gpu-next reshaping") - val current = p.getString("hwdec") - hwdecBeforeDvReshape.set(current ?: "no") - writeProperty("hwdec", "no") - } else { - val restore = hwdecBeforeDvReshape.getAndSet(null) - if (restore != null && restore != "no") writeProperty("hwdec", restore) } setGpuVoRequirement(GpuVoPolicy.REASON_DV_RESHAPE, needs) } + /** + * Per-file Hi10 routing (#2065): an H.264 High 10 stream on hardware that + * advertises no such profile goes straight to software decode on a GL vo, + * instead of letting MediaCodec refuse it and the video chain fail first. + * The profile comes from the container's avcC record (fork patch), so it + * is known inside on_preloaded. Distinct from [GpuVoPolicy.REASON_SW_DECODE]: + * that one follows `hwdec-current`, which is still blank at this point. + */ + private suspend fun applySoftwareDecodePolicy(p: MpvPlayer) { + val track = videoTracks(p).firstOrNull() + val codec = track?.optString("codec") + val codecProfile = track?.optString("codec-profile") + val hardwareHigh10 = MediaCodecQuery.hardwareAvcHigh10Support() + Log.d(TAG, "Decode routing: codec=$codec profile=$codecProfile hardwareHigh10=$hardwareHigh10") + val needs = GpuVoPolicy.needsSoftwareDecode(codec, codecProfile, hardwareHigh10) + if (holdHwdec(p, GpuVoPolicy.REASON_HI10_SW_DECODE, needs) && needs) { + Log.i(TAG, "H.264 High 10 without a hardware profile: software decode on the GL vo") + } + setGpuVoRequirement(GpuVoPolicy.REASON_HI10_SW_DECODE, needs) + } + + /** + * Adds or removes a per-file reason to hold hwdec at `no`. The session's + * own value is parked on the first reason and restored when the last one + * drops (Dart writes meanwhile land in the park, see [setProperty]). + * Returns whether the reason set changed. + */ + private suspend fun holdHwdec(p: MpvPlayer, reason: String, needs: Boolean): Boolean { + val transition: Boolean? = synchronized(hwdecHoldReasons) { + val changed = if (needs) hwdecHoldReasons.add(reason) else hwdecHoldReasons.remove(reason) + if (!changed) return false + val held = hwdecHoldReasons.isNotEmpty() + if (held == hwdecHeld) { + null + } else { + hwdecHeld = held + held + } + } + when (transition) { + true -> { + parkedHwdec.set(p.getString("hwdec") ?: "no") + writeProperty("hwdec", "no") + } + false -> { + val restore = parkedHwdec.getAndSet(null) + if (restore != null && restore != "no") writeProperty("hwdec", restore) + } + null -> {} + } + return true + } + + /** + * Moves the render options between mpv's defaults and the cheap tier as + * the session enters or leaves a GL vo. Why: [GpuVoPolicy.needsCheapRenderTier]. + * Only options still at their mpv default are replaced, so a user's + * mpv.conf line for any of them wins, and only those are restored. + * Serialized on [mpvWriteDispatcher] behind the vo write it follows. + */ + private suspend fun applyRenderTier(p: MpvPlayer, glVoActive: Boolean) { + val wanted = GpuVoPolicy.needsCheapRenderTier( + glVoActive = glVoActive, + textureNorm16 = GlCapabilities.hasTextureNorm16() + ) + val transition: Boolean = synchronized(cheapRenderRestore) { + if (wanted == cheapRenderTierActive) return + cheapRenderTierActive = wanted + wanted + } + if (transition) { + val replaced = LinkedHashMap() + for ((option, cheap) in GpuVoPolicy.CHEAP_RENDER_OPTIONS) { + val current = p.getString(option) + if (!GpuVoPolicy.isDefaultRenderOption(option, current)) { + Log.d(TAG, "Render tier keeps $option=$current (not the mpv default)") + continue + } + replaced[option] = current!! + writeProperty(option, cheap) + } + synchronized(cheapRenderRestore) { cheapRenderRestore.putAll(replaced) } + Log.i(TAG, "Cheap render tier (no GL_EXT_texture_norm16): ${replaced.keys.joinToString(",")}") + } else { + val restore = synchronized(cheapRenderRestore) { LinkedHashMap(cheapRenderRestore).also { cheapRenderRestore.clear() } } + for ((option, value) in restore) writeProperty(option, value) + } + } + /** * Selected video track's Dolby Vision profile, or null for non-DV content * (mpv omits the field when the bitstream carries no DOVI configuration @@ -1466,12 +1582,13 @@ class MpvPlayerCore private constructor( } } - // While the per-file DV policy holds hwdec at `no`, park writes instead - // of applying them: a hardware value under gpu-next would lose the RPU - // side data (and blue-screen the Tegra class, #2010). The parked value - // is restored when a non-P5 file drops the requirement. - if (name == "hwdec" && dvReshapeActive) { - hwdecBeforeDvReshape.set(value) + // While a per-file policy holds hwdec at `no` (DV P5 reshaping, Hi10 + // without a hardware profile), park writes instead of applying them: a + // hardware value under gpu-next would lose the RPU side data (and + // blue-screen the Tegra class, #2010). The parked value is restored when + // the next file drops the last requirement. + if (name == "hwdec" && hwdecHeld) { + parkedHwdec.set(value) onComplete?.invoke(Result.success(Unit)) return } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/GlCapabilities.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/GlCapabilities.kt new file mode 100644 index 000000000..c468a7f54 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/GlCapabilities.kt @@ -0,0 +1,97 @@ +package com.edde746.plezy.shared + +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.EGLContext +import android.opengl.EGLDisplay +import android.opengl.EGLSurface +import android.opengl.GLES20 +import android.util.Log + +/** + * GLES capabilities that decide render policy before mpv creates its own + * context. Probed once per process on a throwaway pbuffer context; the + * answers are hardware/driver properties and do not change at runtime. + */ +internal object GlCapabilities { + private const val TAG = "GlCapabilities" + private const val EGL_OPENGL_ES3_BIT_KHR = 0x0040 + + @Volatile private var textureNorm16: Boolean? = null + + /** + * Whether the GLES driver exposes `GL_EXT_texture_norm16`. Without it mpv + * cannot upload >8-bit planes as filterable textures, and the GPUs that + * lack it are the ones that cannot afford mpv's default scalers either + * (see [com.edde746.plezy.mpv.GpuVoPolicy.needsCheapRenderTier]). + * + * A probe failure reports `true`: the default render path is the safe + * answer for an unknown GPU, not the cheap tier. + */ + fun hasTextureNorm16(): Boolean { + textureNorm16?.let { return it } + synchronized(this) { + textureNorm16?.let { return it } + val probed = probe { extensions -> extensions.contains("GL_EXT_texture_norm16") } ?: true + textureNorm16 = probed + return probed + } + } + + /** Test seam: pretend the probe answered [value]. */ + internal fun overrideTextureNorm16ForTesting(value: Boolean?) { + textureNorm16 = value + } + + /** + * Runs [read] against the extension string of a fresh ES3 pbuffer context + * and tears everything down again. Returns null when EGL refuses any step; + * callers pick the safe default. Must not run on a thread that already + * has an EGL context current — the temporary context replaces it. + */ + private fun probe(read: (String) -> Boolean): Boolean? { + var display: EGLDisplay = EGL14.EGL_NO_DISPLAY + var context: EGLContext = EGL14.EGL_NO_CONTEXT + var surface: EGLSurface = EGL14.EGL_NO_SURFACE + try { + display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + if (display == EGL14.EGL_NO_DISPLAY) return null + val version = IntArray(2) + if (!EGL14.eglInitialize(display, version, 0, version, 1)) return null + val configAttributes = intArrayOf( + EGL14.EGL_SURFACE_TYPE, EGL14.EGL_PBUFFER_BIT, + EGL14.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT_KHR, + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_NONE + ) + val configs = arrayOfNulls(1) + val count = IntArray(1) + if (!EGL14.eglChooseConfig(display, configAttributes, 0, configs, 0, 1, count, 0) || count[0] < 1) return null + val config = configs[0] ?: return null + surface = EGL14.eglCreatePbufferSurface( + display, config, intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE), 0 + ) + if (surface == EGL14.EGL_NO_SURFACE) return null + context = EGL14.eglCreateContext( + display, config, EGL14.EGL_NO_CONTEXT, intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE), 0 + ) + if (context == EGL14.EGL_NO_CONTEXT) return null + if (!EGL14.eglMakeCurrent(display, surface, surface, context)) return null + val extensions = GLES20.glGetString(GLES20.GL_EXTENSIONS) ?: return null + return read(extensions) + } catch (e: RuntimeException) { + Log.w(TAG, "GLES capability probe failed", e) + return null + } finally { + if (display != EGL14.EGL_NO_DISPLAY) { + EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT) + if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context) + if (surface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, surface) + // No eglTerminate: the default display is shared with the Flutter + // engine and mpv; eglInitialize on an initialized display is a no-op. + } + } + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt index 6ea4f620b..f6b771b60 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/MediaCodecQuery.kt @@ -35,6 +35,26 @@ internal object MediaCodecQuery { return mimeTypes } + /** + * Whether a hardware `video/avc` decoder advertises H.264 High 10 (Hi10P). + * Decoders that do not advertise it either refuse the stream (this is what + * the mpv/FFmpeg MediaCodec path sees) or, on some SoCs, accept it and + * render garbage; neither is a reason to let the hardware path try first + * (#2065). Answered once per process: the codec list is static. + */ + fun hardwareAvcHigh10Support(): Boolean = hardwareAvcHigh10.value + + private val hardwareAvcHigh10: Lazy = lazy { + findHardwareDecoder("video/avc") { info, type -> + val profiles = try { + info.getCapabilitiesForType(type).profileLevels + } catch (e: IllegalArgumentException) { + return@findHardwareDecoder false + } + profiles.any { it.profile == MediaCodecInfo.CodecProfileLevel.AVCProfileHigh10 } + } != null + } + fun findHardwareDecoder( mimeType: String, codecKind: Int = MediaCodecList.REGULAR_CODECS, diff --git a/android/app/src/test/kotlin/com/edde746/plezy/mpv/GpuVoPolicyTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/mpv/GpuVoPolicyTest.kt index 578c7b8d4..98f8744ef 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/mpv/GpuVoPolicyTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/mpv/GpuVoPolicyTest.kt @@ -80,6 +80,49 @@ class GpuVoPolicyTest { assertNull(GpuVoPolicy.targetFor(emptySet())) } + @Test + fun `High 10 without a hardware profile is software-decoded up front`() { + assertTrue(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = false)) + assertTrue(GpuVoPolicy.needsSoftwareDecode("h264", "High 10 Intra", hardwareHigh10 = false)) + assertEquals("gpu", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_HI10_SW_DECODE))) + } + + @Test + fun `Hi10 routing leaves every other stream to the hardware path`() { + // A decoder that advertises the profile gets to try. + assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High 10", hardwareHigh10 = true)) + // 8-bit profiles, other codecs, and streams whose container carries no + // profile (Annex B transport streams) are not routed. + assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "High", hardwareHigh10 = false)) + assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "Constrained Baseline", hardwareHigh10 = false)) + assertFalse(GpuVoPolicy.needsSoftwareDecode("hevc", "Main 10", hardwareHigh10 = false)) + assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", null, hardwareHigh10 = false)) + assertFalse(GpuVoPolicy.needsSoftwareDecode("h264", "", hardwareHigh10 = false)) + assertFalse(GpuVoPolicy.needsSoftwareDecode(null, "High 10", hardwareHigh10 = false)) + } + + @Test + fun `cheap render tier needs a GL vo on a driver without norm16`() { + assertTrue(GpuVoPolicy.needsCheapRenderTier(glVoActive = true, textureNorm16 = false)) + // The plane never scales in GL; a capable GPU keeps mpv's defaults. + assertFalse(GpuVoPolicy.needsCheapRenderTier(glVoActive = false, textureNorm16 = false)) + assertFalse(GpuVoPolicy.needsCheapRenderTier(glVoActive = true, textureNorm16 = true)) + } + + @Test + fun `cheap tier replaces only options still at their mpv default`() { + for ((option, defaults) in GpuVoPolicy.MPV_DEFAULT_RENDER_OPTIONS) { + for (default in defaults) assertTrue(option, GpuVoPolicy.isDefaultRenderOption(option, default)) + // A user's mpv.conf value, or an unreadable option, is left alone. + assertFalse(option, GpuVoPolicy.isDefaultRenderOption(option, "ewa_lanczos")) + assertFalse(option, GpuVoPolicy.isDefaultRenderOption(option, null)) + } + assertEquals(GpuVoPolicy.CHEAP_RENDER_OPTIONS.keys, GpuVoPolicy.MPV_DEFAULT_RENDER_OPTIONS.keys) + // cscale's default is "inherit", which mpv 0.41 reads back as empty. + assertTrue(GpuVoPolicy.isDefaultRenderOption("cscale", "")) + assertFalse(GpuVoPolicy.isDefaultRenderOption("scale", "")) + } + @Test fun `dv reshaping targets gpu-next even alongside other reasons`() { assertEquals("gpu-next", GpuVoPolicy.targetFor(setOf(GpuVoPolicy.REASON_DV_RESHAPE))) 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 920514365..591a06fe0 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 @@ -1040,6 +1040,39 @@ class MpvPlayerPluginTest { assertEquals("mediacodec", lastVo()) } + @Test + fun hwdecWritesParkWhileAPerFileHoldIsActive() { + // While DV P5 reshaping or Hi10 routing holds hwdec at `no`, a session + // write of a hardware value must not reach mpv (it would re-enable the + // decoder that was just refused) but must be kept for the restore. + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val writes = ConcurrentLinkedQueue>() + val core = MpvPlayerCore(activity, audioOnly = false, propertyWriter = { name, value -> + writes.add(name to value) + }) + setBoolean(core, "isInitialized", true) + setBoolean(core, "hwdecHeld", true) + + var outcome: Result? = null + core.setProperty("hwdec", "mediacodec,mediacodec-copy") { outcome = it } + awaitCondition { outcome != null } + assertTrue(outcome!!.isSuccess) + assertTrue(writes.isEmpty()) + val parked = MpvPlayerCore::class.java.getDeclaredField("parkedHwdec").run { + isAccessible = true + @Suppress("UNCHECKED_CAST") + (get(core) as java.util.concurrent.atomic.AtomicReference).get() + } + assertEquals("mediacodec,mediacodec-copy", parked) + + // Once the hold is gone, hwdec writes flow through again. + setBoolean(core, "hwdecHeld", false) + outcome = null + core.setProperty("hwdec", "no") { outcome = it } + awaitCondition { outcome != null } + assertEquals(listOf("hwdec" to "no"), writes.toList()) + } + @Test fun dvConversionModeMapsOntoForkDecoderOptions() { // The app-level `dv-conversion-mode` property must translate to the fork diff --git a/android/libmpv/consumer-rules.pro b/android/libmpv/consumer-rules.pro index ab940c646..335fad216 100644 --- a/android/libmpv/consumer-rules.pro +++ b/android/libmpv/consumer-rules.pro @@ -15,4 +15,5 @@ public static void onEvent(int, long, boolean, double, boolean); public static void onEndFile(int, long, boolean); public static void onLogMessage(java.lang.String, int, java.lang.String); + public static void onHook(java.lang.String, long); } diff --git a/android/libmpv/src/main/cpp/event.cpp b/android/libmpv/src/main/cpp/event.cpp index 536712961..219495e8e 100644 --- a/android/libmpv/src/main/cpp/event.cpp +++ b/android/libmpv/src/main/cpp/event.cpp @@ -69,6 +69,14 @@ static void sendLogMessageToJava(JNIEnv* env, mpv_event_log_message* msg) { if (jtext) env->DeleteLocalRef(jtext); } +// A hook holds mpv (playback does not proceed) until Kotlin answers with +// nativeHookContinue(id); MpvPlayer guarantees that answer for every hook. +static void sendHookToJava(JNIEnv* env, mpv_event_hook* hook) { + jstring jname = new_java_string(env, hook->name); + env->CallStaticVoidMethod(mpv_MpvPlayer, mpv_MpvPlayer_onHook, jname, (jlong)hook->id); + if (jname) env->DeleteLocalRef(jname); +} + void* event_thread(void* arg) { JNIEnv* env = NULL; acquire_jni_env(g_vm, &env); @@ -113,6 +121,9 @@ void* event_thread(void* arg) { ALOGV("event: %s\n", mpv_event_name(mp_event->event_id)); sendEventToJava(env, mp_event->event_id, source_id, has_source_id); break; + case MPV_EVENT_HOOK: + sendHookToJava(env, (mpv_event_hook*)mp_event->data); + break; case MPV_EVENT_PLAYBACK_RESTART: { double position_seconds = 0.0; const bool has_position_seconds = diff --git a/android/libmpv/src/main/cpp/jni_utils.cpp b/android/libmpv/src/main/cpp/jni_utils.cpp index c2e48f8da..3058a9ac9 100644 --- a/android/libmpv/src/main/cpp/jni_utils.cpp +++ b/android/libmpv/src/main/cpp/jni_utils.cpp @@ -64,6 +64,7 @@ void init_methods_cache(JNIEnv* env) { mpv_MpvPlayer_onEndFile = env->GetStaticMethodID(mpv_MpvPlayer, "onEndFile", "(IJZ)V"); mpv_MpvPlayer_onLogMessage = env->GetStaticMethodID(mpv_MpvPlayer, "onLogMessage", "(Ljava/lang/String;ILjava/lang/String;)V"); + mpv_MpvPlayer_onHook = env->GetStaticMethodID(mpv_MpvPlayer, "onHook", "(Ljava/lang/String;J)V"); methods_initialized = true; } diff --git a/android/libmpv/src/main/cpp/jni_utils.h b/android/libmpv/src/main/cpp/jni_utils.h index 90add467d..c89d3473c 100644 --- a/android/libmpv/src/main/cpp/jni_utils.h +++ b/android/libmpv/src/main/cpp/jni_utils.h @@ -27,4 +27,4 @@ UTIL_EXTERN jmethodID java_Integer_init, java_Double_init, java_Boolean_init; UTIL_EXTERN jclass mpv_MpvPlayer; UTIL_EXTERN jmethodID mpv_MpvPlayer_onPropertyChanged_SJZ, mpv_MpvPlayer_onPropertyChanged_SZJZ, mpv_MpvPlayer_onPropertyChanged_SJJZ, mpv_MpvPlayer_onPropertyChanged_SDJZ, mpv_MpvPlayer_onPropertyChanged_SSJZ, - mpv_MpvPlayer_onEvent, mpv_MpvPlayer_onEndFile, mpv_MpvPlayer_onLogMessage; + mpv_MpvPlayer_onEvent, mpv_MpvPlayer_onEndFile, mpv_MpvPlayer_onLogMessage, mpv_MpvPlayer_onHook; diff --git a/android/libmpv/src/main/cpp/main.cpp b/android/libmpv/src/main/cpp/main.cpp index 5206fcf6c..42536935e 100644 --- a/android/libmpv/src/main/cpp/main.cpp +++ b/android/libmpv/src/main/cpp/main.cpp @@ -29,6 +29,7 @@ jni_func(void, nativeInit); jni_func(void, nativeDestroy); jni_func(void, nativeCommand, jobjectArray jarray); +jni_func(void, nativeHookContinue, jlong id); }; JavaVM* g_vm; @@ -85,6 +86,12 @@ jni_func(void, nativeInit) { return; } + // Per-file decode routing (Dolby Vision P5, H.264 High 10) has to land + // before mpv creates the decoder; file-loaded is already too late for the + // MediaCodec path. on_preloaded runs after the demuxer opened the file and + // holds playback until Kotlin continues it (MpvPlayer.onHook). + mpv_hook_add(g_mpv, 0, "on_preloaded", 0); + g_event_thread_request_exit = false; if (pthread_create(&event_thread_id, NULL, event_thread, NULL) != 0) { die("thread create failed"); @@ -134,3 +141,8 @@ jni_func(void, nativeCommand, jobjectArray jarray) { mpv_command(g_mpv, arguments); } + +jni_func(void, nativeHookContinue, jlong id) { + if (!g_mpv) return; + mpv_hook_continue(g_mpv, (uint64_t)id); +} diff --git a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt index 0af191b83..3c0ecf315 100644 --- a/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt +++ b/android/libmpv/src/main/java/com/edde746/plezy/libmpv/MpvPlayer.kt @@ -18,10 +18,14 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull class MpvPlayer private constructor() : AutoCloseable { companion object { + /** Upper bound for a hook handler; longer stalls playback start. */ + private const val HOOK_TIMEOUT_MS = 3_000L + init { System.loadLibrary("mpv") System.loadLibrary("player") @@ -127,6 +131,15 @@ class MpvPlayer private constructor() : AutoCloseable { instance.get()?.rawLogMessages?.trySend(LogMessage(prefix, logLevel, text.trimEnd())) } + @JvmStatic + fun onHook(name: String, id: Long) { + val player = instance.get() + if (player == null || player.closed || !player.rawHooks.trySend(Hook(name, id)).isSuccess) { + // Nobody will answer: release mpv rather than leave it waiting. + nativeHookContinue(id) + } + } + private fun checkNotMainThread(operation: String) { check(Looper.myLooper() != Looper.getMainLooper()) { "$operation must not run on the Android main thread" @@ -143,6 +156,8 @@ class MpvPlayer private constructor() : AutoCloseable { @JvmStatic private external fun nativeCommand(cmd: Array) + @JvmStatic private external fun nativeHookContinue(id: Long) + @JvmStatic private external fun nativeSetOptionString(name: String, value: String): Int @JvmStatic private external fun nativeAttachSurface(surface: Surface) @@ -182,6 +197,7 @@ class MpvPlayer private constructor() : AutoCloseable { // buffer, which silently dropped whatever arrived during a burst; losing // e.g. the one cplayer log line that signals a failed video chain. private val rawEvents = Channel(Channel.UNLIMITED) + private val rawHooks = Channel(Channel.UNLIMITED) private val rawPropertyChanges = Channel(Channel.UNLIMITED) private val rawLogMessages = Channel(Channel.UNLIMITED) @@ -191,8 +207,33 @@ class MpvPlayer private constructor() : AutoCloseable { private val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private class Hook(val name: String, val id: Long) + + /** + * Handler for mpv hooks the native side registered (`on_preloaded`). mpv + * holds playback until the handler returns; a handler that throws or + * overruns [HOOK_TIMEOUT_MS] is abandoned and playback continues. Set it + * before loading a file; unset, hooks continue immediately. + */ + @Volatile var hookHandler: (suspend (name: String) -> Unit)? = null + init { pumpScope.launch { for (e in rawEvents) events.emit(e) } + pumpScope.launch { + for (hook in rawHooks) { + try { + val handler = hookHandler + if (handler != null) { + withTimeoutOrNull(HOOK_TIMEOUT_MS) { handler(hook.name) } + ?: android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler overran; continuing playback") + } + } catch (e: Exception) { + android.util.Log.w("MpvPlayer", "Hook ${hook.name} handler failed; continuing playback", e) + } finally { + if (!closed) nativeHookContinue(hook.id) + } + } + } pumpScope.launch { for (c in rawPropertyChanges) propertyChanges.emit(c) } pumpScope.launch { for (m in rawLogMessages) logMessages.emit(m) } } @@ -340,6 +381,7 @@ class MpvPlayer private constructor() : AutoCloseable { // After nativeDestroy no callback can produce: closing the channels // lets each pump drain what is already queued and then complete. rawEvents.close() + rawHooks.close() rawPropertyChanges.close() rawLogMessages.close() } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c5de009e2..281aab10a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -802,7 +802,7 @@ repositoryURL = "https://github.com/edde746/mpv-build"; requirement = { kind = revision; - revision = dafa7762af20052031a7b512c9761a5d8bde327d; + revision = 0601b034da501abc0ce84d2f8fba9e5c9520ad79; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 67e28c3d3..5f42b359b 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,7 +32,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/mpv-build", "state" : { - "revision" : "dafa7762af20052031a7b512c9761a5d8bde327d" + "revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79" } }, { diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 67e28c3d3..5f42b359b 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,7 +32,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/mpv-build", "state" : { - "revision" : "dafa7762af20052031a7b512c9761a5d8bde327d" + "revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79" } }, { diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 5b4b6fc57..31dee210e 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -895,7 +895,7 @@ repositoryURL = "https://github.com/edde746/mpv-build"; requirement = { kind = revision; - revision = dafa7762af20052031a7b512c9761a5d8bde327d; + revision = 0601b034da501abc0ce84d2f8fba9e5c9520ad79; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4d15a24dc..3db971755 100644 --- a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,7 +5,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/mpv-build", "state" : { - "revision" : "dafa7762af20052031a7b512c9761a5d8bde327d" + "revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79" } }, { diff --git a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4d15a24dc..3db971755 100644 --- a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,7 +5,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/mpv-build", "state" : { - "revision" : "dafa7762af20052031a7b512c9761a5d8bde327d" + "revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79" } }, { diff --git a/mpv-build.lock.json b/mpv-build.lock.json index 08b57918d..28cdbbffc 100644 --- a/mpv-build.lock.json +++ b/mpv-build.lock.json @@ -4,54 +4,54 @@ "assetBase": "https://github.com/edde746/mpv-build/releases/download/binaries-android", "assets": { "arm64-v8a": { - "asset": "libmpv-android-a1048f22fa4e-arm64-v8a.tar.gz", - "checksum": "9e26d7faaf4a73ebcb6ffdc3ae96922cd38b2aef36abbb42b56c39f200070969" + "asset": "libmpv-android-4a53a7d47bd8-arm64-v8a.tar.gz", + "checksum": "9aa03e998a3395edb229cfd5529898ad737a2312bae7ab003542a391b594814d" }, "armeabi-v7a": { - "asset": "libmpv-android-a1048f22fa4e-armeabi-v7a.tar.gz", - "checksum": "12295820ed0fde40aa7b2c1080b9bc56f7539d2dea90bb8671c52acf98ec506a" + "asset": "libmpv-android-4a53a7d47bd8-armeabi-v7a.tar.gz", + "checksum": "2353a917174f1cad95deaf3b995578bab4b0f76f23d3c53e198747cdce5207b4" }, "x86": { - "asset": "libmpv-android-a1048f22fa4e-x86.tar.gz", - "checksum": "4abc9a98ac11c6fb170953fe1a352e68113f8bea12525fb448e4ef1aad68e74e" + "asset": "libmpv-android-4a53a7d47bd8-x86.tar.gz", + "checksum": "397f6d9c43c6ac37e1709841164734153e2fc7eda828e91918b44bbb3cbfe275" }, "x86_64": { - "asset": "libmpv-android-a1048f22fa4e-x86_64.tar.gz", - "checksum": "e2bd439df4bd0241e9c3a6035bac5e060dea001c0409f2a0783b08757f52b298" + "asset": "libmpv-android-4a53a7d47bd8-x86_64.tar.gz", + "checksum": "7814b78c80934ec96d3b15aa15c95966e79089bfea1f48d62d1fe467f0209c39" } }, - "key": "a1048f22fa4e" + "key": "4a53a7d47bd8" }, "linux": { "assetBase": "https://github.com/edde746/mpv-build/releases/download/binaries-linux", "assets": { "aarch64": { - "asset": "libmpv-linux-8fb6da562f10-aarch64.tar.zst", - "checksum": "35e02d31b3b3aaa52272bab4dc54ef328fe8ecc92c2eaa2a015c83b72dfa4bc1" + "asset": "libmpv-linux-b366f02906f5-aarch64.tar.zst", + "checksum": "fcc5f917d6c6691ec2c07845040919ec8d833101b4d88bff1dc92c28b4b209a7" }, "x86_64": { - "asset": "libmpv-linux-8fb6da562f10-x86_64.tar.zst", - "checksum": "d3fbbca4d1457dc9205848b50e4483cb33f3877a5c9a3e31f7710f6c95e1e539" + "asset": "libmpv-linux-b366f02906f5-x86_64.tar.zst", + "checksum": "b6182290638c73dc0de0cd91d55df2c389589e1cfbf11a4142d3150a25c95869" } }, - "key": "8fb6da562f10" + "key": "b366f02906f5" }, "windows": { "assetBase": "https://github.com/edde746/mpv-build/releases/download/binaries-windows", "assets": { "aarch64": { - "asset": "libmpv-windows-500299d05130-aarch64.zip", - "checksum": "8a4c1a444ba18b1a7a3418a03b7eab23b1fd3496e8e3a52b73673e89eadb4364" + "asset": "libmpv-windows-93c5f49f80a1-aarch64.zip", + "checksum": "141693ef6d4fede2eec261a6f017a2e48f41f9ff3b8333ea060b81db7188553e" }, "x86_64": { - "asset": "libmpv-windows-500299d05130-x86_64.zip", - "checksum": "30da11572f25a908cdcd4ea57b45930a21917135a68cb24bc32fd0863af610d0" + "asset": "libmpv-windows-93c5f49f80a1-x86_64.zip", + "checksum": "f73be129afc5aae94f472d1be04ac6cc02b6b6a497cb45a821b1dca73b1bf314" } }, - "key": "500299d05130" + "key": "93c5f49f80a1" } }, - "commit": "dafa7762af20052031a7b512c9761a5d8bde327d", + "commit": "0601b034da501abc0ce84d2f8fba9e5c9520ad79", "formatVersion": 1, "repo": "edde746/mpv-build" } diff --git a/tvos/Runner.xcodeproj/project.pbxproj b/tvos/Runner.xcodeproj/project.pbxproj index 072aefcc2..e64f2152c 100644 --- a/tvos/Runner.xcodeproj/project.pbxproj +++ b/tvos/Runner.xcodeproj/project.pbxproj @@ -1175,7 +1175,7 @@ repositoryURL = "https://github.com/edde746/mpv-build"; requirement = { kind = revision; - revision = dafa7762af20052031a7b512c9761a5d8bde327d; + revision = 0601b034da501abc0ce84d2f8fba9e5c9520ad79; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 178b64fb4..b5d5f3af8 100644 --- a/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/tvos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/mpv-build", "state" : { - "revision" : "dafa7762af20052031a7b512c9761a5d8bde327d" + "revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79" } } ], diff --git a/tvos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/tvos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 178b64fb4..b5d5f3af8 100644 --- a/tvos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/tvos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,7 +6,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/edde746/mpv-build", "state" : { - "revision" : "dafa7762af20052031a7b512c9761a5d8bde327d" + "revision" : "0601b034da501abc0ce84d2f8fba9e5c9520ad79" } } ],