From fb4f17a898c5a07deb684a00638c2bc2103c89ee Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:50:53 +0100 Subject: [PATCH] feat: support MKV ContentCompAlgo 0 (zlib) in ExoPlayer --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 4 +- .../exoplayer/ZlibInflatingTrackOutput.kt | 123 ++++++++++++++++++ .../plezy/exoplayer/ZlibMatroskaExtractor.kt | 116 +++++++++++++++++ 3 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index ad529448..d33a7f3a 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -53,7 +53,7 @@ import com.edde746.plezy.shared.AudioFocusManager import com.edde746.plezy.shared.FlutterOverlayHelper import com.edde746.plezy.shared.FrameRateManager import io.github.peerless2012.ass.media.AssHandler -import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor + import io.github.peerless2012.ass.media.factory.AssRenderersFactory import io.github.peerless2012.ass.media.parser.AssSubtitleParserFactory import io.github.peerless2012.ass.media.type.AssRenderType @@ -383,7 +383,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { extractorsFactory.createExtractors().map { extractor -> when { extractor is MatroskaExtractor -> { - val assExtractor = AssMatroskaExtractor(assParserFactory, handler) + val assExtractor = ZlibMatroskaExtractor(assParserFactory, handler) val inner = if (doviEnabled) { DoviExtractorWrapper(assExtractor, currentDvMode).also { activeDoviMkvWrapper = it diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt new file mode 100644 index 00000000..680be898 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibInflatingTrackOutput.kt @@ -0,0 +1,123 @@ +package com.edde746.plezy.exoplayer + +import android.util.Log +import androidx.media3.common.DataReader +import androidx.media3.common.Format +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.extractor.TrackOutput +import java.util.zip.DataFormatException +import java.util.zip.Inflater + +/** + * TrackOutput wrapper that inflates zlib-compressed sample data (MKV ContentCompAlgo 0). + * Each MKV block is independently zlib-compressed; this wrapper decompresses per-sample + * between sampleData() and sampleMetadata() calls. + * + * All buffers are reused across samples to minimize GC pressure on the hot path. + */ +class ZlibInflatingTrackOutput( + private val delegate: TrackOutput, +) : TrackOutput { + + companion object { + private const val TAG = "ZlibTrackOutput" + private const val INITIAL_BUFFER_SIZE = 256 * 1024 + private const val INFLATE_CHUNK = 64 * 1024 + } + + var active = false + + private val inflater = Inflater() + + // Reusable buffers — grown as needed, never shrunk + private var compressedBuf = ByteArray(INITIAL_BUFFER_SIZE) + private var compressedLen = 0 + private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE) + private var readBuf = ByteArray(INFLATE_CHUNK) + private val outputParsable = ParsableByteArray() + private var buffering = false + + override fun format(format: Format) = delegate.format(format) + + override fun sampleData( + input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int + ): Int { + if (!active) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart) + + buffering = true + if (readBuf.size < length) readBuf = ByteArray(length) + val bytesRead = input.read(readBuf, 0, length) + if (bytesRead > 0) appendCompressed(readBuf, 0, bytesRead) + return bytesRead + } + + override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) { + if (!active) { + delegate.sampleData(data, length, sampleDataPart) + return + } + + buffering = true + ensureCompressedCapacity(compressedLen + length) + data.readBytes(compressedBuf, compressedLen, length) + compressedLen += length + } + + override fun sampleMetadata( + timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData? + ) { + if (!active || !buffering) { + delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData) + return + } + + buffering = false + val srcLen = compressedLen + compressedLen = 0 + + val inflatedLen = try { + inflater.reset() + inflater.setInput(compressedBuf, 0, srcLen) + var written = 0 + while (!inflater.finished()) { + if (written == inflateBuf.size) growInflateBuf() + val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written) + if (count == 0 && !inflater.finished()) break + written += count + } + written + } catch (e: DataFormatException) { + Log.e(TAG, "Zlib inflate failed (${srcLen}B), passing raw", e) + // Fall back to raw compressed data + ensureInflateCapacity(srcLen) + System.arraycopy(compressedBuf, 0, inflateBuf, 0, srcLen) + srcLen + } + + outputParsable.reset(inflateBuf, inflatedLen) + delegate.sampleData(outputParsable, inflatedLen, TrackOutput.SAMPLE_DATA_PART_MAIN) + delegate.sampleMetadata(timeUs, flags, inflatedLen, 0, cryptoData) + } + + private fun appendCompressed(src: ByteArray, offset: Int, length: Int) { + ensureCompressedCapacity(compressedLen + length) + System.arraycopy(src, offset, compressedBuf, compressedLen, length) + compressedLen += length + } + + private fun ensureCompressedCapacity(needed: Int) { + if (compressedBuf.size < needed) { + compressedBuf = compressedBuf.copyOf(maxOf(needed, compressedBuf.size * 2)) + } + } + + private fun ensureInflateCapacity(needed: Int) { + if (inflateBuf.size < needed) { + inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2)) + } + } + + private fun growInflateBuf() { + inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2) + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt new file mode 100644 index 00000000..cb69e0be --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ZlibMatroskaExtractor.kt @@ -0,0 +1,116 @@ +package com.edde746.plezy.exoplayer + +import android.util.Log +import androidx.media3.extractor.ExtractorInput +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.SeekMap +import androidx.media3.extractor.TrackOutput +import androidx.media3.extractor.mkv.MatroskaExtractor +import io.github.peerless2012.ass.media.AssHandler +import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor +import androidx.media3.extractor.text.SubtitleParser + +/** + * Extends AssMatroskaExtractor to add support for MKV ContentCompAlgo 0 (zlib). + * + * Media3's MatroskaExtractor only supports ContentCompAlgo 3 (header stripping). + * This subclass intercepts the compression algorithm during track header parsing: + * - Tells the parent it's header stripping (algo 3) to avoid the ParserException + * - Wraps TrackOutputs with ZlibInflatingTrackOutput to decompress per-sample data + * - Skips ContentCompSettings for zlib tracks (not applicable) + */ +class ZlibMatroskaExtractor( + subtitleParserFactory: SubtitleParser.Factory, + assHandler: AssHandler, +) : AssMatroskaExtractor(subtitleParserFactory, assHandler) { + + companion object { + private const val TAG = "ZlibMkvExtractor" + + // Matroska EBML element IDs + private const val ID_SEGMENT = 0x18538067 + private const val ID_TRACK_ENTRY = 0xAE + private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254 + private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255 + + private val extractorOutputField by lazy { + MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply { + isAccessible = true + } + } + } + + private var zlibOutput: ZlibExtractorOutputWrapper? = null + private var currentTrackUsesZlib = false + + override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { + super.startMasterElement(id, contentPosition, contentSize) + + // After super installs AssSubtitleExtractorOutput, wrap it with our zlib layer + if (id == ID_SEGMENT && zlibOutput == null) { + val currentOutput = extractorOutputField.get(this) as ExtractorOutput + val wrapper = ZlibExtractorOutputWrapper(currentOutput) + zlibOutput = wrapper + extractorOutputField.set(this, wrapper) + Log.d(TAG, "Installed zlib ExtractorOutput wrapper") + } + } + + override fun integerElement(id: Int, value: Long) { + if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) { + currentTrackUsesZlib = true + Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples") + // Tell parent it's header stripping (algo 3) to avoid ParserException + super.integerElement(id, 3) + return + } + super.integerElement(id, value) + } + + override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) { + if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) { + // Skip ContentCompSettings for zlib tracks — parent would store these as + // sampleStrippedBytes and prepend them to every sample, corrupting output. + input.skipFully(contentSize) + return + } + super.binaryElement(id, contentSize, input) + } + + override fun endMasterElement(id: Int) { + val wasZlib = currentTrackUsesZlib + super.endMasterElement(id) + + if (id == ID_TRACK_ENTRY && wasZlib) { + zlibOutput?.activateLast() + currentTrackUsesZlib = false + Log.i(TAG, "Activated zlib inflation for track") + } + } + + /** + * ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput. + * Tracks are created inactive; activateLast() enables inflation for the most recently + * created track (called when we know a track uses zlib compression). + */ + private class ZlibExtractorOutputWrapper( + private val delegate: ExtractorOutput, + ) : ExtractorOutput { + + private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null + + override fun track(id: Int, type: Int): TrackOutput { + val original = delegate.track(id, type) + val wrapper = ZlibInflatingTrackOutput(original) + lastCreatedWrapper = wrapper + return wrapper + } + + fun activateLast() { + lastCreatedWrapper?.active = true + } + + override fun endTracks() = delegate.endTracks() + override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap) + } +}