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 000000000..9a6ad9f2a Binary files /dev/null and b/android/app/src/test/resources/matroska_tracks_at_end.mkv differ diff --git a/android/libass/build.gradle.kts b/android/libass/build.gradle.kts index ba1708fb9..6998c8234 100644 --- a/android/libass/build.gradle.kts +++ b/android/libass/build.gradle.kts @@ -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") }