Apply the subtitle delay on ExoPlayer #74

Merged
justinzeus merged 3 commits from fm/sub-offset-core into fm/tv-round2 2026-11-08 01:45:22 +01:00
3 changed files with 190 additions and 0 deletions
Showing only changes of commit 0a7c059f24 - Show all commits

View file

@ -479,6 +479,8 @@ dependencies {
testImplementation("com.squareup.okhttp3:mockwebserver3")
testImplementation(libs.androidx.core.testing)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.media3.test.utils)
testImplementation(libs.androidx.media3.test.utils.robolectric)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0")

View file

@ -0,0 +1,186 @@
@file:OptIn(markerClass = [UnstableApi::class])
package com.github.damontecres.wholphin.services
import android.content.Context
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.test.utils.FakeClock
import androidx.media3.test.utils.robolectric.CapturingRenderersFactory
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.play
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.run
import androidx.test.core.app.ApplicationProvider
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.abs
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* Plays a short audio file with a sideloaded SRT through a real [ExoPlayer] and checks when the
* subtitles appear for a given delay, and that playback still ends when the media does.
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [30])
class SubtitleDelayPlaybackTest {
@get:Rule val folder = TemporaryFolder()
private val context = ApplicationProvider.getApplicationContext<Context>()
private val clock = FakeClock(true)
private val shownCues = mutableListOf<Pair<String, Long>>()
private val playbackStates = mutableListOf<Int>()
private lateinit var player: ExoPlayer
@Before
fun setUp() {
player =
ExoPlayer
.Builder(context, SubtitleDelayRenderersFactory(CapturingRenderersFactory(context, clock)))
.setMediaSourceFactory(DefaultMediaSourceFactory(context))
.setClock(clock)
.build()
player.addListener(
object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
cueGroup.cues.firstOrNull()?.let { shownCues.add(it.text.toString() to player.currentPosition) }
}
override fun onPlaybackStateChanged(playbackState: Int) {
playbackStates.add(playbackState)
}
},
)
player.setMediaItem(mediaItem())
player.prepare()
// Subtitles never hold up playback, so let the sideloaded file load before playing
run(player).untilState(Player.STATE_READY)
run(player).untilLoadingIs(false)
assertTrue(player.currentTracks.isTypeSelected(C.TRACK_TYPE_TEXT))
}
@After
fun tearDown() {
player.release()
}
@Test
fun positiveDelayShowsSubtitlesLaterAndPlaybackEndsOnTime() {
player.applySubtitleDelay(2.seconds)
val playStartMs = clock.elapsedRealtime()
play(player).untilState(Player.STATE_ENDED)
assertShownAt("First" to 3.seconds)
val playedMs = clock.elapsedRealtime() - playStartMs
assertTrue(
"ended after $playedMs ms of playback, expected $DURATION",
abs(playedMs - DURATION.inWholeMilliseconds) <= TOLERANCE_MS,
)
}
@Test
fun negativeDelayShowsSubtitlesEarlier() {
player.applySubtitleDelay((-500).milliseconds)
play(player).untilState(Player.STATE_ENDED)
assertShownAt("First" to 500.milliseconds, "Last" to 2.seconds)
}
@Test
fun changingTheDelayWhilePlayingRetimesWithoutRebuffering() {
play(player).untilPosition(0, 500)
player.applySubtitleDelay(1.seconds)
play(player).untilState(Player.STATE_ENDED)
assertShownAt("First" to 2.seconds, "Last" to 3_500.milliseconds)
assertEquals(listOf(Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED), playbackStates)
}
private fun assertShownAt(vararg expected: Pair<String, Duration>) {
assertEquals(expected.map { it.first }, shownCues.map { it.first })
expected.zip(shownCues).forEach { (want, got) ->
assertTrue(
"${want.first} shown at ${got.second} ms, expected ${want.second}",
abs(got.second - want.second.inWholeMilliseconds) <= TOLERANCE_MS,
)
}
}
private fun mediaItem(): MediaItem {
val audio = folder.newFile("silence.wav").apply { writeBytes(silentWav()) }
val subtitles = folder.newFile("subtitles.srt").apply { writeText(SRT) }
return MediaItem
.Builder()
.setUri(Uri.fromFile(audio))
.setSubtitleConfigurations(
listOf(
MediaItem.SubtitleConfiguration
.Builder(Uri.fromFile(subtitles))
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
.setLanguage("en")
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.build(),
),
).build()
}
/** A mono 16-bit PCM WAV file of silence lasting [DURATION] */
private fun silentWav(): ByteArray {
val dataSize = (SAMPLE_RATE * DURATION.inWholeMilliseconds / 1000 * 2).toInt()
return ByteBuffer
.allocate(44 + dataSize)
.order(ByteOrder.LITTLE_ENDIAN)
.put("RIFF".toByteArray())
.putInt(36 + dataSize)
.put("WAVEfmt ".toByteArray())
.putInt(16)
.putShort(1)
.putShort(1)
.putInt(SAMPLE_RATE)
.putInt(SAMPLE_RATE * 2)
.putShort(2)
.putShort(16)
.put("data".toByteArray())
.putInt(dataSize)
.array()
}
private companion object {
const val SAMPLE_RATE = 8_000
const val TOLERANCE_MS = 100L
val DURATION = 4.seconds
val SRT =
"""
1
00:00:01,000 --> 00:00:02,000
First
2
00:00:02,500 --> 00:00:03,500
Last
""".trimIndent()
}
}

View file

@ -120,6 +120,8 @@ androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-da
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" }
androidx-media3-test-utils = { module = "androidx.media3:media3-test-utils", version.ref = "androidx-media3" }
androidx-media3-test-utils-robolectric = { module = "androidx.media3:media3-test-utils-robolectric", version.ref = "androidx-media3" }
exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" }
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }