From a629a3407af4e510e186d152cde4c4d8e67ec4ba Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:05:14 +0200 Subject: [PATCH] fix(player): route Hi10 to software up front and drop to bilinear on GPUs without norm16 H.264 High 10 on Android TV boxes without a 10-bit hardware decoder (Amlogic S905X4 class: onn 4K Pro, Homatics Box R) started black for over ten seconds and then played choppy: mpv tried MediaCodec first, hit "Could not initialize video chain", and only then fell back to software decode on the GL vo, where the Mali-G31 driver has no GL_EXT_texture_norm16 and pays an integer-texture conversion pass plus lanczos scaling it cannot afford at 1080p. Decide the decoder before mpv creates it. An on_preloaded hook now carries both per-file policies (Dolby Vision P5 reshaping and this one): when the track's codec-profile is High 10 (published at demux by the fork, see the mpv-build pin) and MediaCodecList advertises no hardware AVCProfileHigh10, hwdec is held at `no` and the session goes to the GL vo directly. The hold parks any hwdec write from Dart and restores it for the next file, as the DV P5 hold already did. On a GL vo whose driver lacks norm16 (probed once through a pbuffer EGL context), scale/cscale/dscale drop to bilinear and dither to off for the session, restored when the vo goes back to the plane. Only options still at their mpv defaults are touched, so a user's mpv.conf wins. Paired with the fork's rg8-backed 16-bit plane emulation, the box goes from 13 drops/s and a 13.7 s first frame to 0.7 drops/s and 2.5 s. Devices with norm16 (Shield, Pixel 7) take neither the tier nor the emulation. The native pin moves to edde746/mpv-build@0601b034da50, which also picks up the earlier android/linux/windows patch-series squash. close #2065 --- .../com/edde746/plezy/mpv/GpuVoPolicy.kt | 50 +++++ .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 181 ++++++++++++++---- .../edde746/plezy/shared/GlCapabilities.kt | 97 ++++++++++ .../edde746/plezy/shared/MediaCodecQuery.kt | 20 ++ .../com/edde746/plezy/mpv/GpuVoPolicyTest.kt | 43 +++++ .../edde746/plezy/mpv/MpvPlayerPluginTest.kt | 33 ++++ android/libmpv/consumer-rules.pro | 1 + android/libmpv/src/main/cpp/event.cpp | 11 ++ android/libmpv/src/main/cpp/jni_utils.cpp | 1 + android/libmpv/src/main/cpp/jni_utils.h | 2 +- android/libmpv/src/main/cpp/main.cpp | 12 ++ .../com/edde746/plezy/libmpv/MpvPlayer.kt | 42 ++++ ios/Runner.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- macos/Runner.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- mpv-build.lock.json | 40 ++-- tvos/Runner.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- 22 files changed, 489 insertions(+), 62 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/GlCapabilities.kt 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" } } ],