Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ object PlayerModule {
.setMediaCodecSelector(PlaybackMediaCodecSelector)

return ExoPlayer.Builder(context, renderersFactory)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
.setMediaSourceFactory(
DefaultMediaSourceFactory(dataSourceFactory, ZlibSubtitleExtractorsFactory()),
)
.build()
}

Expand All @@ -49,7 +51,9 @@ object PlayerModule {
.setMediaCodecSelector(PlaybackMediaCodecSelector)

return ExoPlayer.Builder(context, renderersFactory)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
.setMediaSourceFactory(
DefaultMediaSourceFactory(dataSourceFactory, ZlibSubtitleExtractorsFactory()),
)
.build()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.miruplay.tv.player

import androidx.media3.common.Format
import androidx.media3.common.util.Consumer
import androidx.media3.common.util.UnstableApi
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorsFactory
import androidx.media3.extractor.text.DefaultSubtitleParserFactory
import androidx.media3.extractor.text.SubtitleParser
import java.io.ByteArrayOutputStream
import java.util.zip.DataFormatException
import java.util.zip.Inflater

/**
* Media3 handles Matroska header stripping but not zlib-compressed subtitle samples.
* This factory keeps the stock extractors and inflates text samples before parsing.
*/
@UnstableApi
internal class ZlibSubtitleExtractorsFactory(
private val delegate: DefaultExtractorsFactory = DefaultExtractorsFactory()
.setSubtitleParserFactory(ZlibSubtitleParserFactory(DefaultSubtitleParserFactory()))
.experimentalSetTextTrackTranscodingEnabled(true),
) : ExtractorsFactory {

override fun createExtractors(): Array<Extractor> = delegate.createExtractors()

override fun experimentalSetTextTrackTranscodingEnabled(enabled: Boolean): ExtractorsFactory {
delegate.experimentalSetTextTrackTranscodingEnabled(enabled)
return this
}

override fun setSubtitleParserFactory(factory: SubtitleParser.Factory): ExtractorsFactory {
delegate.setSubtitleParserFactory(ZlibSubtitleParserFactory(factory))
return this
}
}

@UnstableApi
private class ZlibSubtitleParserFactory(
private val delegate: SubtitleParser.Factory,
) : SubtitleParser.Factory {

override fun supportsFormat(format: Format): Boolean = delegate.supportsFormat(format)

override fun getCueReplacementBehavior(format: Format): Int =
delegate.getCueReplacementBehavior(format)

override fun create(format: Format): SubtitleParser =
ZlibSubtitleParser(delegate.create(format))
}

@UnstableApi
private class ZlibSubtitleParser(
private val delegate: SubtitleParser,
) : SubtitleParser {

override fun parse(
data: ByteArray,
offset: Int,
length: Int,
outputOptions: SubtitleParser.OutputOptions,
output: Consumer<androidx.media3.extractor.text.CuesWithTiming>,
) {
val sample = data.copyOfRange(offset, offset + length)
val inflated = inflateSubtitleSampleIfNeeded(sample)
delegate.parse(inflated, 0, inflated.size, outputOptions, output)
}

override fun getCueReplacementBehavior(): Int = delegate.getCueReplacementBehavior()

override fun reset() {
delegate.reset()
}
}

internal fun inflateSubtitleSampleIfNeeded(input: ByteArray): ByteArray {
if (input.size < 2) return input

for (start in 0..input.lastIndex - 1) {
if (!isZlibHeader(input, start)) continue
val inflated = inflateFrom(input, start) ?: continue
val candidate = input.copyOfRange(0, start) + inflated
if (looksLikeSubtitleText(candidate)) return candidate
}
return input
}

private fun isZlibHeader(input: ByteArray, index: Int): Boolean {
val compressionMethod = input[index].toInt() and 0x0F
val flags = input[index + 1].toInt() and 0xFF
val header = ((input[index].toInt() and 0xFF) shl 8) or flags
return compressionMethod == 8 && header % 31 == 0
}

private fun inflateFrom(input: ByteArray, start: Int): ByteArray? {
val inflater = Inflater()
return try {
inflater.setInput(input, start, input.size - start)
val output = ByteArrayOutputStream(maxOf(32, (input.size - start) * 2))
val buffer = ByteArray(8 * 1024)
while (!inflater.finished()) {
val count = inflater.inflate(buffer)
if (count > 0) {
output.write(buffer, 0, count)
} else if (inflater.needsDictionary() || inflater.needsInput()) {
return null
} else {
return null
}
}
output.toByteArray()
} catch (_: DataFormatException) {
null
} finally {
inflater.end()
}
}

private fun looksLikeSubtitleText(bytes: ByteArray): Boolean {
if (bytes.isEmpty()) return false
val text = bytes.toString(Charsets.UTF_8)
if (text.indexOf('\uFFFD') >= 0) return false
return text.contains("Dialogue:") ||
text.contains("--> ") ||
text.contains("WEBVTT") ||
text.contains("<tt")
}
Comment on lines +77 to +128

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cap the decompressed size to prevent a zlib-bomb OOM.

inflateFrom has no limit on the decompressed output size. ByteArrayOutputStream keeps growing on every inflater.inflate call until the stream reports finished(). A crafted subtitle sample with a small compressed size but a huge decompressed size (a zip bomb) can exhaust memory and crash playback. looksLikeSubtitleText then decodes the unbounded candidate as UTF-8, compounding the cost. Since subtitle samples can originate from untrusted or remote media, bound the inflate loop.

🛡️ Proposed fix to cap decompressed subtitle size
+private const val MAX_INFLATED_SUBTITLE_SIZE_BYTES = 4 * 1024 * 1024
+
 private fun inflateFrom(input: ByteArray, start: Int): ByteArray? {
     val inflater = Inflater()
     return try {
         inflater.setInput(input, start, input.size - start)
         val output = ByteArrayOutputStream(maxOf(32, (input.size - start) * 2))
         val buffer = ByteArray(8 * 1024)
         while (!inflater.finished()) {
             val count = inflater.inflate(buffer)
             if (count > 0) {
                 output.write(buffer, 0, count)
+                if (output.size() > MAX_INFLATED_SUBTITLE_SIZE_BYTES) return null
             } else if (inflater.needsDictionary() || inflater.needsInput()) {
                 return null
             } else {
                 return null
             }
         }
         output.toByteArray()
     } catch (_: DataFormatException) {
         null
     } finally {
         inflater.end()
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
internal fun inflateSubtitleSampleIfNeeded(input: ByteArray): ByteArray {
if (input.size < 2) return input
for (start in 0..input.lastIndex - 1) {
if (!isZlibHeader(input, start)) continue
val inflated = inflateFrom(input, start) ?: continue
val candidate = input.copyOfRange(0, start) + inflated
if (looksLikeSubtitleText(candidate)) return candidate
}
return input
}
private fun isZlibHeader(input: ByteArray, index: Int): Boolean {
val compressionMethod = input[index].toInt() and 0x0F
val flags = input[index + 1].toInt() and 0xFF
val header = ((input[index].toInt() and 0xFF) shl 8) or flags
return compressionMethod == 8 && header % 31 == 0
}
private fun inflateFrom(input: ByteArray, start: Int): ByteArray? {
val inflater = Inflater()
return try {
inflater.setInput(input, start, input.size - start)
val output = ByteArrayOutputStream(maxOf(32, (input.size - start) * 2))
val buffer = ByteArray(8 * 1024)
while (!inflater.finished()) {
val count = inflater.inflate(buffer)
if (count > 0) {
output.write(buffer, 0, count)
} else if (inflater.needsDictionary() || inflater.needsInput()) {
return null
} else {
return null
}
}
output.toByteArray()
} catch (_: DataFormatException) {
null
} finally {
inflater.end()
}
}
private fun looksLikeSubtitleText(bytes: ByteArray): Boolean {
if (bytes.isEmpty()) return false
val text = bytes.toString(Charsets.UTF_8)
if (text.indexOf('\uFFFD') >= 0) return false
return text.contains("Dialogue:") ||
text.contains("--> ") ||
text.contains("WEBVTT") ||
text.contains("<tt")
}
internal fun inflateSubtitleSampleIfNeeded(input: ByteArray): ByteArray {
if (input.size < 2) return input
for (start in 0..input.lastIndex - 1) {
if (!isZlibHeader(input, start)) continue
val inflated = inflateFrom(input, start) ?: continue
val candidate = input.copyOfRange(0, start) + inflated
if (looksLikeSubtitleText(candidate)) return candidate
}
return input
}
private fun isZlibHeader(input: ByteArray, index: Int): Boolean {
val compressionMethod = input[index].toInt() and 0x0F
val flags = input[index + 1].toInt() and 0xFF
val header = ((input[index].toInt() and 0xFF) shl 8) or flags
return compressionMethod == 8 && header % 31 == 0
}
private const val MAX_INFLATED_SUBTITLE_SIZE_BYTES = 4 * 1024 * 1024
private fun inflateFrom(input: ByteArray, start: Int): ByteArray? {
val inflater = Inflater()
return try {
inflater.setInput(input, start, input.size - start)
val output = ByteArrayOutputStream(maxOf(32, (input.size - start) * 2))
val buffer = ByteArray(8 * 1024)
while (!inflater.finished()) {
val count = inflater.inflate(buffer)
if (count > 0) {
output.write(buffer, 0, count)
if (output.size() > MAX_INFLATED_SUBTITLE_SIZE_BYTES) return null
} else if (inflater.needsDictionary() || inflater.needsInput()) {
return null
} else {
return null
}
}
output.toByteArray()
} catch (_: DataFormatException) {
null
} finally {
inflater.end()
}
}
private fun looksLikeSubtitleText(bytes: ByteArray): Boolean {
if (bytes.isEmpty()) return false
val text = bytes.toString(Charsets.UTF_8)
if (text.indexOf('\uFFFD') >= 0) return false
return text.contains("Dialogue:") ||
text.contains("--> ") ||
text.contains("WEBVTT") ||
text.contains("<tt")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@player-core/src/main/kotlin/com/miruplay/tv/player/ZlibSubtitleExtractor.kt`
around lines 77 - 128, Cap decompressed output in inflateFrom before writing to
ByteArrayOutputStream, using a finite subtitle-sized maximum and stopping or
returning null when the limit would be exceeded. Ensure the loop cannot allocate
beyond that bound, so looksLikeSubtitleText only receives bounded candidates
while normal valid subtitle inflation remains unchanged.

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.miruplay.tv.player

import java.util.zip.Deflater
import org.junit.Assert.assertArrayEquals
import org.junit.Test

class ZlibSubtitleSampleTest {

@Test
fun `zlib compressed subtitle sample is inflated`() {
val ass = "Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Hello\n".toByteArray()

val result = inflateSubtitleSampleIfNeeded(deflate(ass))

assertArrayEquals(ass, result)
}

@Test
fun `matroska dialogue prefix is preserved while zlib payload is inflated`() {
val prefix = "Dialogue: 0:00:00:00,0:00:02:16,"
val payload = "Default,,0,0,0,,Hello\n".toByteArray()
val sample = prefix.toByteArray() + deflate(payload)

assertArrayEquals(
prefix.toByteArray() + payload,
inflateSubtitleSampleIfNeeded(sample),
)
}

@Test
fun `plain subtitle sample is returned unchanged`() {
val ass = "Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Hello\n".toByteArray()

assertArrayEquals(ass, inflateSubtitleSampleIfNeeded(ass))
}

@Test
fun `invalid compressed-looking sample is returned unchanged`() {
val bytes = byteArrayOf(0x78.toByte(), 0x9c.toByte(), 0x01, 0x02, 0x03)

assertArrayEquals(bytes, inflateSubtitleSampleIfNeeded(bytes))
}

private fun deflate(input: ByteArray): ByteArray {
val deflater = Deflater()
deflater.setInput(input)
deflater.finish()
val output = ByteArray(input.size + 64)
val length = deflater.deflate(output)
deflater.end()
return output.copyOf(length)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ class MiruMpvSurfaceView @JvmOverloads constructor(
MPVLib.setOptionString("hwdec-codecs", EMBEDDED_MPV_HWDEC_CODECS)
MPVLib.setOptionString("vo", sessionOptions.vo)
MPVLib.setOptionString("save-position-on-quit", "no")
// Keep authored ASS styles and positions intact; libass owns subtitle layout.
mpvSubtitleLayoutNormalisationOptions.forEach { (name, value) ->
MPVLib.setOptionString(name, value)
}
applyColorPipelineProperties(sessionOptions)
applyShaderProperties(sessionOptions.shaderPaths)
sessionOptions.extraOptions.forEach { (name, value) ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package `is`.xyz.mpv

/** mpv subtitle options intentionally left empty so libass keeps authored ASS layout. */
internal val mpvSubtitleLayoutNormalisationOptions: List<Pair<String, String>> = emptyList()
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package `is`.xyz.mpv

import org.junit.Assert.assertEquals
import org.junit.Test

class MpvSubtitleLayoutTest {

@Test
fun `subtitle layout leaves ass positioning to libass`() {
assertEquals(
emptyList<Pair<String, String>>(),
mpvSubtitleLayoutNormalisationOptions,
)
}
}
29 changes: 26 additions & 3 deletions ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ import androidx.core.view.doOnLayout
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.common.Player
import androidx.media3.common.text.CueGroup
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.PlayerView
import androidx.annotation.LayoutRes
Expand Down Expand Up @@ -187,14 +189,21 @@ internal fun subtitleCaptionStyle(
)
}

private fun PlayerView.applySubtitleBackgroundPreference(transparentBackground: Boolean) {
private fun resolveSubtitleCaptionStyle(
context: Context,
transparentBackground: Boolean,
): CaptionStyleCompat {
val captioningManager = context.getSystemService(CaptioningManager::class.java)
val baseStyle = captioningManager
?.takeIf { it.isEnabled }
?.userStyle
?.let(CaptionStyleCompat::createFromCaptionStyle)
?: CaptionStyleCompat.DEFAULT
subtitleView?.setStyle(subtitleCaptionStyle(baseStyle, transparentBackground))
return subtitleCaptionStyle(baseStyle, transparentBackground)
}

private fun PlayerView.applySubtitleBackgroundPreference(transparentBackground: Boolean) {
subtitleView?.setStyle(resolveSubtitleCaptionStyle(context, transparentBackground))
}

@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalLayoutApi::class)
Expand Down Expand Up @@ -573,6 +582,20 @@ private fun PlayerScreenContent(
) {
val player = viewModel.getPlayer()
val usesNativeVideoHost = viewModel.usesVlcVideoLayout()
DisposableEffect(player) {
val cueListener = object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
val normalizedCues = restackSubtitleCues(cueGroup.cues)
// PlayerView owns the subtitle layer; post after its internal
// listener so the normalized cues remain the final render input.
playerViewRef?.subtitleView?.post {
playerViewRef?.subtitleView?.setCues(normalizedCues)
}
}
}
player?.addListener(cueListener)
onDispose { player?.removeListener(cueListener) }
}
if (shouldShowExperimentalSurface) {
AndroidView(
factory = { context ->
Expand Down Expand Up @@ -675,7 +698,7 @@ private fun PlayerScreenContent(
},
modifier = Modifier.fillMaxSize()
)
}
}
} else {
Box(
modifier = Modifier.fillMaxSize().background(Color.Black),
Expand Down
Loading
Loading