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.
This commit is contained in:
edde746
2026-09-06 13:22:20 +02:00
parent ce3a3b177c
commit ac7fb87ade
3 changed files with 97 additions and 16 deletions
@@ -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>): 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,
@@ -139,6 +139,9 @@ class MpvPlayerCore private constructor(
private val parkedHwdec = java.util.concurrent.atomic.AtomicReference<String?>()
/** 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<String, String>()
@@ -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
@@ -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))