From ac7fb87ade8f1b23622e6b3ce154ab3249cb4d0b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:22:20 +0200 Subject: [PATCH] fix(android): route decode policy by the video track mpv will select The Dolby Vision and Hi10 decode policies run in mpv's on_preloaded hook, before track selection, and read the first demuxed video track. A file whose first video track is not the one mpv picks (attached pictures, a default-flagged second track, --vlang, an explicit vid) got the wrong routing. The hook now resolves one pending track for both policies: an explicit vid=no or vid=N is authoritative on its own, and auto asks the fork's pending-vid property, which runs mpv's own default selection ahead of time. On a libmpv without that property the first-track guess stays, logged once. The predecoder gate is unchanged (#2065). The fork patch lands separately through the native pin flow. --- .../com/edde746/plezy/mpv/GpuVoPolicy.kt | 23 ++++++++ .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 56 +++++++++++++------ .../com/edde746/plezy/mpv/GpuVoPolicyTest.kt | 34 +++++++++++ 3 files changed, 97 insertions(+), 16 deletions(-) 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 c76fc7d8a..53605e2ca 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 @@ -48,6 +48,29 @@ internal object GpuVoPolicy { */ fun needsSoftwareDecode(codec: String?, codecProfile: String?, hardwareHigh10: Boolean): Boolean = !hardwareHigh10 && codec == "h264" && codecProfile?.startsWith("High 10") == true + /** + * The video track the per-file policies ([needsDvReshaping], + * [needsSoftwareDecode]) decide for, or null when no video track will be + * selected. They run inside on_preloaded, where the track list is complete + * but nothing is selected yet, so "the selected track" does not exist: + * `vid` still reads the option value, and mpv's own selection + * (default/forced flags, --vlang, attached pictures skipped) only runs + * after the hook. [vid] is the `vid` property: `no` and an explicit id are + * authoritative on their own; `auto` defers to [pendingVid], the fork's + * `pending-vid` property, which runs that selection ahead of time. A null + * [pendingVid] means the libmpv has no such property, and the first track + * in [videoTrackIds] (track-list order) is the best guess left — wrong for + * files whose first video track is not the one mpv picks. + */ + fun pendingVideoTrackId(vid: String?, pendingVid: String?, videoTrackIds: List): Long? { + val requested = when { + vid == null || vid == "auto" -> pendingVid ?: return videoTrackIds.firstOrNull() + else -> vid + } + val id = requested.toLongOrNull() ?: return null + return id.takeIf { it in videoTrackIds } + } + /** * 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, 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 a9596b237..6ce02b496 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 @@ -139,6 +139,9 @@ class MpvPlayerCore private constructor( private val parkedHwdec = java.util.concurrent.atomic.AtomicReference() + /** Whether the missing `pending-vid` property was logged; hook-serial. */ + private var pendingVidUnavailableLogged = false + /** 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() @@ -498,12 +501,15 @@ class MpvPlayerCore private constructor( // 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). + // MediaCodec decoder is already created by then (#2065). Nothing + // is selected yet either, so the track both policies decide for + // is resolved once here, from mpv's pending selection. p.hookHandler = { name -> if (name == "on_preloaded" && !disposing) { withContext(mpvWriteDispatcher) { - applyDvReshapePolicy(p) - applySoftwareDecodePolicy(p) + val track = pendingVideoTrack(p) + applyDvReshapePolicy(p, track) + applySoftwareDecodePolicy(p, track) } } } @@ -847,12 +853,14 @@ class MpvPlayerCore private constructor( /** * Per-file Dolby Vision routing, decided from the bitstream: mpv exports * the DOVI configuration record's profile on the track list (never trust - * server metadata for this — it mis-tags DV routinely). Re-evaluated on - * every file-loaded, so a following non-P5 file restores hardware decode - * and returns to the video plane. + * server metadata for this — it mis-tags DV routinely; mpv omits the + * field when the bitstream carries no record). Re-evaluated on every + * file, so a following non-P5 file restores hardware decode and returns + * to the video plane. [track] is the pending video track, see + * [pendingVideoTrack]. */ - private suspend fun applyDvReshapePolicy(p: MpvPlayer) { - val profile = selectedVideoDvProfile(p) + private suspend fun applyDvReshapePolicy(p: MpvPlayer, track: org.json.JSONObject?) { + val profile = track?.takeIf { it.has("dolby-vision-profile") }?.getLong("dolby-vision-profile") val needs = GpuVoPolicy.needsDvReshaping( dvProfile = profile, conversionMode = currentDvConversionMode, @@ -871,9 +879,9 @@ class MpvPlayerCore private constructor( * 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. + * [track] is the pending video track, see [pendingVideoTrack]. */ - private suspend fun applySoftwareDecodePolicy(p: MpvPlayer) { - val track = videoTracks(p).firstOrNull() + private suspend fun applySoftwareDecodePolicy(p: MpvPlayer, track: org.json.JSONObject?) { val codec = track?.optString("codec") val codecProfile = track?.optString("codec-profile") val hardwareHigh10 = MediaCodecQuery.hardwareAvcHigh10Support() @@ -954,13 +962,29 @@ class MpvPlayerCore private constructor( } /** - * Selected video track's Dolby Vision profile, or null for non-DV content - * (mpv omits the field when the bitstream carries no DOVI configuration - * record). + * The video track mpv is about to select, resolved inside on_preloaded + * where nothing is selected yet. `vid=no` and an explicit `vid=N` answer + * on their own (the hook never re-selects: an explicit choice stays the + * user's); `auto` asks the fork's `pending-vid`, which runs mpv's own + * default selection ahead of time. Without that property (a libmpv + * predating the fork patch) the first track is the only guess left; the + * gap is logged once so a wrong policy on a multi-video file is traceable. + * Decision in [GpuVoPolicy.pendingVideoTrackId]. */ - private suspend fun selectedVideoDvProfile(p: MpvPlayer): Long? = videoTracks(p).firstOrNull() - ?.takeIf { it.has("dolby-vision-profile") } - ?.getLong("dolby-vision-profile") + private suspend fun pendingVideoTrack(p: MpvPlayer): org.json.JSONObject? { + val tracks = videoTracks(p) + if (tracks.isEmpty()) return null + val vid = p.getString("vid") + val auto = vid == null || vid == "auto" + val pendingVid = if (auto) p.getString("pending-vid") else null + if (auto && pendingVid == null && !pendingVidUnavailableLogged) { + pendingVidUnavailableLogged = true + Log.w(TAG, "libmpv has no pending-vid property; decode routing assumes the first video track") + } + val id = GpuVoPolicy.pendingVideoTrackId(vid, pendingVid, tracks.map { it.optLong("id") }) + Log.d(TAG, "Pending video track: vid=$vid pending-vid=$pendingVid -> ${id ?: "none"} of ${tracks.size}") + return tracks.firstOrNull { it.optLong("id") == id } + } /** * Observed rather than derived from the hardware-decoding setting because 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 98f8744ef..935a1a685 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 @@ -101,6 +101,40 @@ class GpuVoPolicyTest { assertFalse(GpuVoPolicy.needsSoftwareDecode(null, "High 10", hardwareHigh10 = false)) } + // The per-file policies run inside on_preloaded, before mpv selects a + // track, so the track they decide for comes from the pending selection. + + @Test + fun `auto selection follows mpv's pending choice, not track-list order`() { + // Cover art first, the default-flagged feature second: mpv picks 2. + assertEquals(2L, GpuVoPolicy.pendingVideoTrackId("auto", "2", listOf(1L, 2L))) + assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("auto", "1", listOf(1L, 2L))) + } + + @Test + fun `explicit vid answers on its own`() { + assertEquals(2L, GpuVoPolicy.pendingVideoTrackId("2", pendingVid = null, videoTrackIds = listOf(1L, 2L))) + // A user's explicit choice is never re-selected, even when mpv would + // pick differently. + assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("1", "2", listOf(1L, 2L))) + // No such track: mpv selects nothing, so nothing is routed. + assertNull(GpuVoPolicy.pendingVideoTrackId("7", "2", listOf(1L, 2L))) + } + + @Test + fun `no video selection yields no track`() { + assertNull(GpuVoPolicy.pendingVideoTrackId("no", "1", listOf(1L, 2L))) + assertNull(GpuVoPolicy.pendingVideoTrackId("auto", "no", listOf(1L, 2L))) + assertNull(GpuVoPolicy.pendingVideoTrackId("auto", "1", emptyList())) + } + + @Test + fun `without the pending-vid property the first track is the fallback`() { + assertEquals(1L, GpuVoPolicy.pendingVideoTrackId("auto", null, listOf(1L, 2L))) + assertEquals(1L, GpuVoPolicy.pendingVideoTrackId(null, null, listOf(1L, 2L))) + assertNull(GpuVoPolicy.pendingVideoTrackId("auto", null, emptyList())) + } + @Test fun `cheap render tier needs a GL vo on a driver without norm16`() { assertTrue(GpuVoPolicy.needsCheapRenderTier(glVoActive = true, textureNorm16 = false))