Add an end-to-end subtitle delay playback test
This commit is contained in:
parent
7c03a3fe0d
commit
0a7c059f24
3 changed files with 190 additions and 0 deletions
|
|
@ -479,6 +479,8 @@ dependencies {
|
||||||
testImplementation("com.squareup.okhttp3:mockwebserver3")
|
testImplementation("com.squareup.okhttp3:mockwebserver3")
|
||||||
testImplementation(libs.androidx.core.testing)
|
testImplementation(libs.androidx.core.testing)
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
|
testImplementation(libs.androidx.media3.test.utils)
|
||||||
|
testImplementation(libs.androidx.media3.test.utils.robolectric)
|
||||||
testImplementation(libs.hilt.android.testing)
|
testImplementation(libs.hilt.android.testing)
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0")
|
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = { 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-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-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" }
|
exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" }
|
||||||
|
|
||||||
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue