From 5a0b5954668bdbad99879b675de1cd01bdf2fee6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:08:00 +0200 Subject: [PATCH] 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 --- android/app/build.gradle.kts | 2 +- .../plezy/exoplayer/PlezyRenderersFactory.kt | 11 +- .../plezy/exoplayer/TrueHdCarrierSink.kt | 12 +- .../BufferedTransformingTrackOutputTest.kt | 11 ++ .../plezy/exoplayer/MatroskaLateTracksTest.kt | 105 ++++++++++++++++++ .../plezy/exoplayer/TrueHdCarrierSinkTest.kt | 60 ++++++---- .../exoplayer/ZlibInflatingTrackOutputTest.kt | 11 ++ .../test/resources/matroska_tracks_at_end.mkv | Bin 0 -> 16571 bytes android/libass/build.gradle.kts | 4 +- 9 files changed, 181 insertions(+), 35 deletions(-) create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt create mode 100644 android/app/src/test/resources/matroska_tracks_at_end.mkv diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c7e33d29d..5a0650d99 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt index af371f8cf..0cd2e8d75 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt @@ -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() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt index 276843713..972a02c7b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSink.kt @@ -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) } } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt index f4c65e5d7..c37e81df1 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferedTransformingTrackOutputTest.kt @@ -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() { diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt new file mode 100644 index 000000000..8e127a6d5 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/MatroskaLateTracksTest.kt @@ -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() + + 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) + } + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSinkTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSinkTest.kt index 8947aa68d..555266ee1 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSinkTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/TrueHdCarrierSinkTest.kt @@ -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 diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt index be08598ea..35c29815f 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutputTest.kt @@ -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() { diff --git a/android/app/src/test/resources/matroska_tracks_at_end.mkv b/android/app/src/test/resources/matroska_tracks_at_end.mkv new file mode 100644 index 0000000000000000000000000000000000000000..9a6ad9f2a35e50854d5f72cd00955b6d490215f3 GIT binary patch literal 16571 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