feat: support MKV ContentCompAlgo 0 (zlib) in ExoPlayer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user