diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/DiModule.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/DiModule.kt index 949cd961..86b96565 100644 --- a/player-core/src/main/kotlin/com/miruplay/tv/player/DiModule.kt +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/DiModule.kt @@ -30,7 +30,9 @@ object PlayerModule { .setMediaCodecSelector(PlaybackMediaCodecSelector) return ExoPlayer.Builder(context, renderersFactory) - .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .setMediaSourceFactory( + DefaultMediaSourceFactory(dataSourceFactory, ZlibSubtitleExtractorsFactory()), + ) .build() } @@ -49,7 +51,9 @@ object PlayerModule { .setMediaCodecSelector(PlaybackMediaCodecSelector) return ExoPlayer.Builder(context, renderersFactory) - .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .setMediaSourceFactory( + DefaultMediaSourceFactory(dataSourceFactory, ZlibSubtitleExtractorsFactory()), + ) .build() } diff --git a/player-core/src/main/kotlin/com/miruplay/tv/player/ZlibSubtitleExtractor.kt b/player-core/src/main/kotlin/com/miruplay/tv/player/ZlibSubtitleExtractor.kt new file mode 100644 index 00000000..74eac2d5 --- /dev/null +++ b/player-core/src/main/kotlin/com/miruplay/tv/player/ZlibSubtitleExtractor.kt @@ -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 = 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, + ) { + 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(" + MPVLib.setOptionString(name, value) + } applyColorPipelineProperties(sessionOptions) applyShaderProperties(sessionOptions.shaderPaths) sessionOptions.extraOptions.forEach { (name, value) -> diff --git a/player-mpv-android/src/main/kotlin/is/xyz/mpv/MpvSubtitleLayout.kt b/player-mpv-android/src/main/kotlin/is/xyz/mpv/MpvSubtitleLayout.kt new file mode 100644 index 00000000..dc0f602c --- /dev/null +++ b/player-mpv-android/src/main/kotlin/is/xyz/mpv/MpvSubtitleLayout.kt @@ -0,0 +1,4 @@ +package `is`.xyz.mpv + +/** mpv subtitle options intentionally left empty so libass keeps authored ASS layout. */ +internal val mpvSubtitleLayoutNormalisationOptions: List> = emptyList() diff --git a/player-mpv-android/src/test/kotlin/is/xyz/mpv/MpvSubtitleLayoutTest.kt b/player-mpv-android/src/test/kotlin/is/xyz/mpv/MpvSubtitleLayoutTest.kt new file mode 100644 index 00000000..5d1bec0f --- /dev/null +++ b/player-mpv-android/src/test/kotlin/is/xyz/mpv/MpvSubtitleLayoutTest.kt @@ -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>(), + mpvSubtitleLayoutNormalisationOptions, + ) + } +} diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt index 41394219..3d061d56 100644 --- a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt @@ -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 @@ -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) @@ -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 -> @@ -675,7 +698,7 @@ private fun PlayerScreenContent( }, modifier = Modifier.fillMaxSize() ) - } + } } else { Box( modifier = Modifier.fillMaxSize().background(Color.Black), diff --git a/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/SubtitleCueLayout.kt b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/SubtitleCueLayout.kt new file mode 100644 index 00000000..1f0e3a5a --- /dev/null +++ b/ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/SubtitleCueLayout.kt @@ -0,0 +1,117 @@ +@file:Suppress("UnsafeOptInUsageError") + +package com.miruplay.tv.ui.player + +import android.text.Layout +import android.text.SpannableStringBuilder +import androidx.media3.common.text.Cue + +/** Maximum number of simultaneous ordinary dialogue cues retained per group. */ +internal const val MAX_STACKED_SUBTITLE_CUES = 4 + +private enum class SubtitleRegion { + START, + MIDDLE, + END, +} + +private data class DialogueGroupKey( + val vertical: SubtitleRegion, + val horizontal: SubtitleRegion, + val textAlignment: Layout.Alignment?, +) + +/** + * Merge default-position text cues into measured blocks without disturbing signs, + * effects, bitmap subtitles, or cues carrying explicit ASS coordinates. + */ +internal fun restackSubtitleCues( + cues: List, + maxStackedCues: Int = MAX_STACKED_SUBTITLE_CUES, +): List { + if (cues.isEmpty()) return emptyList() + + val effectiveMax = maxStackedCues.coerceAtLeast(1) + val groups = linkedMapOf>() + val cueGroups = arrayOfNulls(cues.size) + cues.forEachIndexed { index, cue -> + cue.dialogueGroupKey()?.let { key -> + cueGroups[index] = key + groups.getOrPut(key) { mutableListOf() }.add(cue) + } + } + + if (groups.isEmpty()) return cues + + val emittedGroups = mutableSetOf() + return buildList(cues.size) { + cues.forEachIndexed { index, cue -> + val key = cueGroups[index] + if (key == null) { + add(cue) + } else if (emittedGroups.add(key)) { + add(buildMergedCue(key, groups.getValue(key), effectiveMax)) + } + } + } +} + +private fun Cue.dialogueGroupKey(): DialogueGroupKey? { + if (text == null || bitmap != null || size != Cue.DIMEN_UNSET) return null + val vertical = defaultRegion(line, lineAnchor, lineType, default = SubtitleRegion.END) ?: return null + val horizontal = defaultRegion( + position, + positionAnchor, + Cue.LINE_TYPE_FRACTION, + default = SubtitleRegion.MIDDLE, + ) ?: return null + return DialogueGroupKey(vertical, horizontal, textAlignment) +} + +private fun defaultRegion( + value: Float, + anchor: Int, + type: Int, + default: SubtitleRegion, +): SubtitleRegion? { + if (value == Cue.DIMEN_UNSET) return default + if (type != Cue.LINE_TYPE_FRACTION) return null + return when { + value.isCloseTo(0.05f) && anchor == Cue.ANCHOR_TYPE_START -> SubtitleRegion.START + value.isCloseTo(0.5f) && anchor == Cue.ANCHOR_TYPE_MIDDLE -> SubtitleRegion.MIDDLE + value.isCloseTo(0.95f) && anchor == Cue.ANCHOR_TYPE_END -> SubtitleRegion.END + else -> null + } +} + +private fun Float.isCloseTo(expected: Float): Boolean = + kotlin.math.abs(this - expected) < 0.001f + +private fun buildMergedCue( + key: DialogueGroupKey, + cues: List, + maxStackedCues: Int, +): Cue { + val retained = cues + .takeLast(maxStackedCues) + .asReversed() + .distinctBy { it.text.toString() } + .asReversed() + val first = retained.first() + val mergedText = SpannableStringBuilder().apply { + retained.forEachIndexed { index, cue -> + if (index > 0) append('\n') + append(cue.text) + } + } + return first.buildUpon() + .setText(mergedText) + .setSize(Cue.DIMEN_UNSET) + .apply { + if (key.vertical == SubtitleRegion.END) { + setLine(-1f, Cue.LINE_TYPE_NUMBER) + setLineAnchor(Cue.ANCHOR_TYPE_END) + } + } + .build() +} diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/SubtitleCueLayoutTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/SubtitleCueLayoutTest.kt new file mode 100644 index 00000000..e6f0074e --- /dev/null +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/SubtitleCueLayoutTest.kt @@ -0,0 +1,154 @@ +@file:Suppress("UnsafeOptInUsageError") + +package com.miruplay.tv.ui.player + +import android.graphics.Bitmap +import android.graphics.Typeface +import android.text.SpannableString +import android.text.Spanned +import android.text.style.StyleSpan +import androidx.media3.common.text.Cue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SubtitleCueLayoutTest { + + private fun cue(text: String): Cue = Cue.Builder().setText(text).build() + + private fun positionedDialogue( + text: String, + position: Float, + positionAnchor: Int, + line: Float, + lineAnchor: Int, + ): Cue = Cue.Builder() + .setText(text) + .setPosition(position) + .setPositionAnchor(positionAnchor) + .setLine(line, Cue.LINE_TYPE_FRACTION) + .setLineAnchor(lineAnchor) + .build() + + @Test + fun `empty cues returns empty list`() { + assertTrue(restackSubtitleCues(emptyList()).isEmpty()) + } + + @Test + fun `single cue is placed one line above the bottom`() { + val result = restackSubtitleCues(listOf(cue("JP"))) + + assertEquals(1, result.size) + assertEquals("JP", result[0].text.toString()) + assertEquals(-1f, result[0].line, 0.001f) + assertEquals(Cue.LINE_TYPE_NUMBER, result[0].lineType) + assertEquals(Cue.ANCHOR_TYPE_END, result[0].lineAnchor) + // Horizontal position/size cleared so the cue centres and never overflows. + assertEquals(Cue.DIMEN_UNSET, result[0].position, 0.001f) + assertEquals(Cue.TYPE_UNSET, result[0].positionAnchor) + assertEquals(Cue.DIMEN_UNSET, result[0].size, 0.001f) + } + + @Test + fun `two bilingual cues merge into one measured block`() { + val result = restackSubtitleCues(listOf(cue("JP"), cue("CN"))) + + assertEquals(1, result.size) + assertEquals("JP\nCN", result[0].text.toString()) + assertEquals(-1f, result[0].line, 0.001f) + } + + @Test + fun `duplicate text layers are shown once within the same dialogue group`() { + val result = restackSubtitleCues(listOf(cue("JP"), cue("JP"), cue("CN"))) + + assertEquals(1, result.size) + assertEquals("JP\nCN", result.single().text.toString()) + } + + @Test + fun `dialogue groups remain separate by vertical and horizontal region`() { + val result = restackSubtitleCues( + listOf( + positionedDialogue("top left", 0.05f, Cue.ANCHOR_TYPE_START, 0.05f, Cue.ANCHOR_TYPE_START), + positionedDialogue("top left 2", 0.05f, Cue.ANCHOR_TYPE_START, 0.05f, Cue.ANCHOR_TYPE_START), + positionedDialogue("bottom centre", 0.5f, Cue.ANCHOR_TYPE_MIDDLE, 0.95f, Cue.ANCHOR_TYPE_END), + positionedDialogue("bottom right", 0.95f, Cue.ANCHOR_TYPE_END, 0.95f, Cue.ANCHOR_TYPE_END), + ), + ) + + assertEquals(3, result.size) + assertEquals("top left\ntop left 2", result[0].text.toString()) + assertEquals(0.05f, result[0].position, 0.001f) + assertEquals(Cue.ANCHOR_TYPE_START, result[0].positionAnchor) + assertEquals("bottom centre", result[1].text.toString()) + assertEquals(-1f, result[1].line, 0.001f) + assertEquals("bottom right", result[2].text.toString()) + assertEquals(0.95f, result[2].position, 0.001f) + assertEquals(Cue.ANCHOR_TYPE_END, result[2].positionAnchor) + } + + @Test + fun `multiline cue and sibling cue cannot occupy separate overlapping bands`() { + val result = restackSubtitleCues(listOf(cue("JP line 1\nJP line 2"), cue("CN"))) + + assertEquals(1, result.size) + assertEquals("JP line 1\nJP line 2\nCN", result[0].text.toString()) + } + + @Test + fun `cues beyond the cap are dropped keeping the most recent`() { + val cues = (1..6).map { cue("line$it") } + + val result = restackSubtitleCues(cues) + + assertEquals(1, result.size) + assertEquals("line3\nline4\nline5\nline6", result[0].text.toString()) + assertEquals(-1f, result[0].line, 0.001f) + } + + @Test + fun `explicitly positioned text sign is preserved beside merged dialogue`() { + val positioned = Cue.Builder() + .setText("sign") + .setPosition(0.2f) + .setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) + .setLine(0.3f, Cue.LINE_TYPE_FRACTION) + .setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) + .setSize(0.2f) + .build() + + val result = restackSubtitleCues(listOf(cue("JP"), positioned, cue("CN"))) + + assertEquals(2, result.size) + assertEquals("JP\nCN", result[0].text.toString()) + assertSame(positioned, result[1]) + } + + @Test + fun `merge preserves styled text and bitmap cue order`() { + val styledText = SpannableString("JP").apply { + setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + val textCue = Cue.Builder().setText(styledText).build() + val firstBitmapCue = Cue.Builder() + .setBitmap(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) + .build() + val secondBitmapCue = Cue.Builder() + .setBitmap(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)) + .build() + + val result = restackSubtitleCues(listOf(textCue, firstBitmapCue, cue("CN"), secondBitmapCue)) + + assertEquals(3, result.size) + assertEquals("JP\nCN", result[0].text.toString()) + assertEquals(Typeface.BOLD, (result[0].text as Spanned).getSpans(0, 2, StyleSpan::class.java).single().style) + assertSame(firstBitmapCue, result[1]) + assertSame(secondBitmapCue, result[2]) + } +} diff --git a/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/SubtitleCueLv999FixtureTest.kt b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/SubtitleCueLv999FixtureTest.kt new file mode 100644 index 00000000..d5923bf8 --- /dev/null +++ b/ui-tv/src/test/kotlin/com/miruplay/tv/ui/player/SubtitleCueLv999FixtureTest.kt @@ -0,0 +1,77 @@ +@file:Suppress("UnsafeOptInUsageError") + +package com.miruplay.tv.ui.player + +import androidx.media3.common.text.Cue +import androidx.media3.extractor.text.CuesWithTiming +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.text.ssa.SsaParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SubtitleCueLv999FixtureTest { + + @Test + fun `LV999 dialogue merges while positioned sign keeps ASS placement`() { + val frames = parseFixture() + + val fourDialogueCues = frameAt(frames, 47_190_000) + assertEquals(4, fourDialogueCues.size) + assertEquals(listOf(0.5f, 0.5f, 0.5f, 0.5f), fourDialogueCues.map(Cue::position)) + assertEquals(listOf(0.95f, 0.95f, 0.95f, 0.95f), fourDialogueCues.map(Cue::line)) + assertEquals( + "First dialogue JP\nFirst dialogue CN\nSecond dialogue JP\nSecond dialogue CN", + restackSubtitleCues(fourDialogueCues).single().text.toString(), + ) + + val dialogueAndSign = frameAt(frames, 108_000_000) + assertEquals(3, dialogueAndSign.size) + val positionedSign = dialogueAndSign.single { it.text.toString() == "LV999 guild notice" } + assertEquals(340f / 1920f, positionedSign.position, 0.0001f) + assertEquals(240f / 1080f, positionedSign.line, 0.0001f) + + val restackedWithSign = restackSubtitleCues(dialogueAndSign) + assertEquals(2, restackedWithSign.size) + assertEquals("Dialogue JP\nDialogue CN", restackedWithSign[0].text.toString()) + assertSame(positionedSign, restackedWithSign[1]) + + val nextDialogue = restackSubtitleCues(frameAt(frames, 110_500_000)) + assertEquals(1, nextDialogue.size) + assertEquals("Next dialogue JP\nNext dialogue CN", nextDialogue.single().text.toString()) + } + + private fun parseFixture(): List { + val ass = """ + [Script Info] + PlayResX: 1920 + PlayResY: 1080 + + [V4+ Styles] + Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding + Style: Dialogue,Arial,50,&H00FFFFFF,&H000000FF,&H00000000,&H64000000,0,0,0,0,100,100,0,0,1,2,0,2,20,20,40,1 + Style: Sign,Arial,36,&H00FFFFFF,&H000000FF,&H00000000,&H64000000,0,0,0,0,100,100,0,0,1,2,0,5,20,20,20,1 + + [Events] + Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text + Dialogue: 0,0:00:46.90,0:00:47.60,Dialogue,,40,900,40,,First dialogue JP + Dialogue: 0,0:00:46.90,0:00:47.60,Dialogue,,900,40,40,,First dialogue CN + Dialogue: 0,0:00:47.00,0:00:47.50,Dialogue,,60,880,80,,Second dialogue JP + Dialogue: 0,0:00:47.00,0:00:47.50,Dialogue,,880,60,80,,Second dialogue CN + Dialogue: 0,0:01:47.90,0:01:48.30,Dialogue,,40,900,40,,Dialogue JP + Dialogue: 0,0:01:47.90,0:01:48.30,Dialogue,,900,40,40,,Dialogue CN + Dialogue: 5,0:01:47.50,0:01:48.60,Sign,,0,0,0,,{\pos(340,240)}LV999 guild notice + Dialogue: 0,0:01:50.40,0:01:50.80,Dialogue,,40,900,40,,Next dialogue JP + Dialogue: 0,0:01:50.40,0:01:50.80,Dialogue,,900,40,40,,Next dialogue CN + """.trimIndent().toByteArray() + return buildList { + SsaParser().parse(ass, SubtitleParser.OutputOptions.allCues()) { add(it) } + } + } + + private fun frameAt(frames: List, positionUs: Long): List = + frames.single { positionUs >= it.startTimeUs && positionUs < it.endTimeUs }.cues +}