fix(android): play MKV files with late track metadata

MKV files whose Tracks element follows media clusters could direct-play with audio but no video. Upgrade Media3 to 1.11.0 and cover the extractor regression.

close #1947
This commit is contained in:
edde746
2026-08-15 19:08:00 +02:00
parent 35b3e97730
commit 5a0b595466
9 changed files with 181 additions and 35 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
val mpvAar = "libmpv-release.aar"
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
val media3Version = "1.10.1"
val media3Version = "1.11.0"
val mpvFfmpegVersion = "8.0.1"
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
@@ -166,16 +166,15 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParams: Boolean
): AudioSink {
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
// Media3 1.11's replacement is fixed at 500ms; preserve the dynamic 500–1000ms PCM policy.
@Suppress("DEPRECATION")
val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder()
.setMinPcmBufferDurationUs(500_000)
.setMaxPcmBufferDurationUs(1_000_000)
.setPcmBufferMultiplicationFactor(4)
// media3 defaults passthrough to 250ms, which the AC3 factor doubles to 500ms — 40000
// bytes at 640 kbps. Some HDMI routes reject a buffer that short outright and the only
// retry media3 1.10.1 has is to keep halving it (#1790). Ask for a second up front;
// upstream adopted the same 1s floor as its last-resort retry in #3207.
// Media3 defaults passthrough to 250ms, which the AC3 factor doubles to 500ms. Some
// HDMI routes reject a buffer that short (#1790). Ask for a second up front; Media3 1.11
// now uses the same one-second floor for its last-resort retry (#3207).
.setPassthroughBufferDurationUs(500_000)
.build()
@@ -165,7 +165,8 @@ internal class TrueHdCarrierSink(
else -> defaultSink.getFormatSupport(format)
}
override fun configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) {
override fun configure(audioSinkConfig: AudioSink.AudioSinkConfig) {
val inputFormat = audioSinkConfig.format
val useCarrier = shouldUseCarrier(inputFormat)
if (useCarrier != carrierActive) {
log?.invoke(
@@ -185,9 +186,14 @@ internal class TrueHdCarrierSink(
discardCarrierState()
if (useCarrier) {
carrierSink.configure(carrierFormat(), specifiedBufferSize, null)
val carrierConfig = AudioSink.AudioSinkConfig.Builder(carrierFormat())
.setPreferredBufferSizeOverride(audioSinkConfig.preferredBufferSizeOverride)
.setTimeline(audioSinkConfig.timeline)
.setMediaPeriodId(audioSinkConfig.mediaPeriodId)
.build()
carrierSink.configure(carrierConfig)
} else {
defaultSink.configure(inputFormat, specifiedBufferSize, outputChannels)
defaultSink.configure(audioSinkConfig)
}
}
@@ -8,12 +8,23 @@ import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.ByteArrayOutputStream
import java.io.EOFException
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
class BufferedTransformingTrackOutputTest {
@Before
fun enableMedia3TestBoundsChecks() {
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(true)
}
@After
fun resetMedia3TestBoundsChecks() {
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(null)
}
@Test
fun activeTransformBuffersChunksAndEmitsOneNormalizedSample() {
@@ -0,0 +1,105 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.DefaultExtractorInput
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import com.edde746.plezy.libass.media.AssHandler
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Regression coverage for Matroska files whose SeekHead references Tracks after the Clusters. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MatroskaLateTracksTest {
private class CapturedTrack(val type: Int) : TrackOutput {
var format: Format? = null
var sampleCount = 0
override fun format(format: Format) {
this.format = format
}
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int = input.read(ByteArray(length), 0, length)
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
data.skipBytes(length)
}
override fun sampleMetadata(timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?) {
sampleCount++
}
}
private class CapturingExtractorOutput : ExtractorOutput {
val tracks = mutableMapOf<Int, CapturedTrack>()
override fun track(id: Int, type: Int): TrackOutput = tracks.getOrPut(id) { CapturedTrack(type) }
override fun endTracks() = Unit
override fun seekMap(seekMap: SeekMap) = Unit
}
private class ByteArrayDataReader(private val data: ByteArray) : DataReader {
var position = 0L
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (position >= data.size) return C.RESULT_END_OF_INPUT
val bytesRead = minOf(length, data.size - position.toInt())
data.copyInto(buffer, offset, position.toInt(), position.toInt() + bytesRead)
position += bytesRead
return bytesRead
}
}
private fun fixtureData(): ByteArray = checkNotNull(javaClass.getResourceAsStream("/matroska_tracks_at_end.mkv")) {
"fixture matroska_tracks_at_end.mkv missing from test resources"
}.use { it.readBytes() }
private fun extractFixture(): CapturingExtractorOutput {
val data = fixtureData()
val assHandler = AssHandler()
val extractor = ZlibMatroskaExtractor(AssSubtitleParserFactory(assHandler), assHandler)
val output = CapturingExtractorOutput()
extractor.init(output)
val reader = ByteArrayDataReader(data)
var input: ExtractorInput = DefaultExtractorInput(reader, 0, data.size.toLong())
val seekPosition = PositionHolder()
repeat(100_000) {
when (extractor.read(input, seekPosition)) {
Extractor.RESULT_END_OF_INPUT -> return output
Extractor.RESULT_SEEK -> {
reader.position = seekPosition.position
input = DefaultExtractorInput(reader, seekPosition.position, data.size.toLong())
}
}
}
error("extractor did not reach end of input")
}
@Test
fun extractsSamplesWhenSeekHeadReferencesTracksAfterClusters() {
val output = extractFixture()
assertEquals(2, output.tracks.size)
assertEquals(setOf(C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_AUDIO), output.tracks.values.map { it.type }.toSet())
output.tracks.values.forEach { track ->
assertNotNull(track.format)
assertTrue("expected extracted samples for track type ${track.type}", track.sampleCount > 0)
}
}
}
@@ -8,10 +8,12 @@ import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Timeline
import androidx.media3.common.util.Clock
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.source.MediaSource
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import org.junit.Assert.assertArrayEquals
@@ -44,6 +46,8 @@ class TrueHdCarrierSinkTest {
.setSampleRate(sampleRate)
.build()
private fun audioSinkConfig(format: Format = trueHdFormat()) = AudioSink.AudioSinkConfig.Builder(format).build()
private fun sink(
carrier: FakeSink = FakeSink(),
normal: FakeSink = FakeSink(),
@@ -109,7 +113,7 @@ class TrueHdCarrierSinkTest {
val carrierSink = sink(carrier = carrier)
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(audioSinkConfig())
assertTrue("carrier should be live before the speed change", carrierSink.isCarrierActive)
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
@@ -130,7 +134,7 @@ class TrueHdCarrierSinkTest {
val carrierSink = sink()
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(audioSinkConfig())
carrierSink.setPlaybackParameters(PlaybackParameters(1.5f))
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
@@ -145,7 +149,7 @@ class TrueHdCarrierSinkTest {
val carrierSink = sink()
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(audioSinkConfig())
carrierSink.setPlaybackParameters(PlaybackParameters(1f))
@@ -163,7 +167,7 @@ class TrueHdCarrierSinkTest {
val carrierSink = sink(carrier = carrier)
val listener = RecordingSinkListener()
carrierSink.setListener(listener)
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(audioSinkConfig())
val buffer = ByteBuffer.wrap(fortyFourFamilyAccessUnits())
val accepted = carrierSink.handleBuffer(buffer, 0L, 1)
@@ -188,7 +192,7 @@ class TrueHdCarrierSinkTest {
fun theMismatchLatchSurvivesTheResetThatAccompaniesTheHandover() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(audioSinkConfig())
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
carrierSink.flush()
@@ -206,7 +210,7 @@ class TrueHdCarrierSinkTest {
fun releasingTheSinkClearsTheMismatchLatch() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(audioSinkConfig())
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
carrierSink.release()
@@ -236,7 +240,7 @@ class TrueHdCarrierSinkTest {
fun aNewMediaItemClearsTheMismatchLatch() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(audioSinkConfig())
carrierSink.handleBuffer(ByteBuffer.wrap(fortyFourFamilyAccessUnits()), 0L, 1)
assertEquals(
AudioSink.SINK_FORMAT_UNSUPPORTED,
@@ -261,7 +265,7 @@ class TrueHdCarrierSinkTest {
fun aLateMismatchFromThePreviousItemDoesNotPoisonTheNewOne() {
val carrierSink = sink()
carrierSink.setListener(RecordingSinkListener())
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(audioSinkConfig())
// The new item starts before the outgoing stream's last buffer is handled.
carrierSink.beginMediaItem()
@@ -294,7 +298,7 @@ class TrueHdCarrierSinkTest {
fun aRefusedBurstMidSampleLosesNoAccessUnits() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(audioSinkConfig())
// Refuse every third burst once, so rejections land inside samples rather than between them.
carrier.refuseEveryNth = 3
@@ -309,7 +313,7 @@ class TrueHdCarrierSinkTest {
fun theCarrierOutputMatchesTheGoldenStream() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(audioSinkConfig())
assertArrayEquals(golden, feedWholeStream(carrierSink))
}
@@ -319,7 +323,7 @@ class TrueHdCarrierSinkTest {
fun aPendingBurstIsPlacedBeforeMoreInputIsConsumed() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(audioSinkConfig())
carrier.refuseNext = true
val buffer = ByteBuffer.wrap(accessUnits)
@@ -362,14 +366,22 @@ class TrueHdCarrierSinkTest {
fun theCarrierDelegateIsConfiguredAsAFixedRatePcmCarrier() {
val carrier = FakeSink()
val carrierSink = sink(carrier = carrier)
val inputConfig = AudioSink.AudioSinkConfig.Builder(trueHdFormat())
.setPreferredBufferSizeOverride(4_096)
.setTimeline(Timeline.EMPTY)
.setMediaPeriodId(MediaSource.MediaPeriodId("period"))
.build()
carrierSink.configure(trueHdFormat(), 0, null)
carrierSink.configure(inputConfig)
val configured = checkNotNull(carrier.configuredFormat)
assertEquals(MimeTypes.AUDIO_RAW, configured.sampleMimeType)
assertEquals(C.ENCODING_PCM_16BIT, configured.pcmEncoding)
assertEquals(TrueHdMatPacker.CARRIER_SAMPLE_RATE, configured.sampleRate)
assertEquals(TrueHdMatPacker.CARRIER_CHANNEL_COUNT, configured.channelCount)
val configured = checkNotNull(carrier.configuredConfig)
assertEquals(inputConfig.preferredBufferSizeOverride, configured.preferredBufferSizeOverride)
assertSame(inputConfig.timeline, configured.timeline)
assertEquals(inputConfig.mediaPeriodId, configured.mediaPeriodId)
assertEquals(MimeTypes.AUDIO_RAW, configured.format.sampleMimeType)
assertEquals(C.ENCODING_PCM_16BIT, configured.format.pcmEncoding)
assertEquals(TrueHdMatPacker.CARRIER_SAMPLE_RATE, configured.format.sampleRate)
assertEquals(TrueHdMatPacker.CARRIER_CHANNEL_COUNT, configured.format.channelCount)
}
@Test
@@ -378,11 +390,13 @@ class TrueHdCarrierSinkTest {
val normal = FakeSink()
val carrierSink = sink(carrier = carrier, normal = normal)
val ac3 = Format.Builder().setSampleMimeType(MimeTypes.AUDIO_AC3).setSampleRate(48_000).build()
val config = audioSinkConfig(ac3)
carrierSink.configure(ac3, 0, null)
carrierSink.configure(config)
assertSame(ac3, normal.configuredFormat)
assertEquals(null, carrier.configuredFormat)
assertSame(config, normal.configuredConfig)
assertSame(ac3, normal.configuredConfig?.format)
assertEquals(null, carrier.configuredConfig)
}
private fun feedWholeStream(carrierSink: TrueHdCarrierSink): ByteArray {
@@ -416,7 +430,7 @@ class TrueHdCarrierSinkTest {
/** Minimal AudioSink that records what it was handed and can apply back pressure. */
private class FakeSink : AudioSink {
val written = ByteArrayOutputStream()
var configuredFormat: Format? = null
var configuredConfig: AudioSink.AudioSinkConfig? = null
var formatSupport: Int = AudioSink.SINK_FORMAT_UNSUPPORTED
var lastVolume: Float = -1f
var sessionId: Int = -1
@@ -444,8 +458,8 @@ class TrueHdCarrierSinkTest {
return true
}
override fun configure(inputFormat: Format, specifiedBufferSize: Int, outputChannels: IntArray?) {
configuredFormat = inputFormat
override fun configure(audioSinkConfig: AudioSink.AudioSinkConfig) {
configuredConfig = audioSinkConfig
}
override fun supportsFormat(format: Format) = formatSupport == AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY
@@ -9,13 +9,24 @@ import androidx.media3.extractor.TrackOutput
import java.io.ByteArrayOutputStream
import java.util.zip.Deflater
import java.util.zip.DeflaterOutputStream
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ZlibInflatingTrackOutputTest {
@Before
fun enableMedia3TestBoundsChecks() {
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(true)
}
@After
fun resetMedia3TestBoundsChecks() {
ParsableByteArray.setShouldEnforceLimitOnLegacyMethods(null)
}
@Test
fun activeTransformInflatesChunkedSampleAndPreservesMetadata() {
+2 -2
View File
@@ -54,8 +54,8 @@ kotlin {
dependencies {
implementation("androidx.annotation:annotation:1.10.0")
implementation("androidx.annotation:annotation-experimental:1.6.0")
implementation("androidx.media3:media3-exoplayer:1.10.1")
implementation("androidx.media3:media3-ui:1.10.1")
implementation("androidx.media3:media3-exoplayer:1.11.0")
implementation("androidx.media3:media3-ui:1.11.0")
testImplementation("junit:junit:4.13.2")
}