Apply the subtitle delay on ExoPlayer #74
3 changed files with 190 additions and 0 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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-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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue