diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b5190ad..05c2e073 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayPlaybackTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayPlaybackTest.kt new file mode 100644 index 00000000..5a4a275f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayPlaybackTest.kt @@ -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() + private val clock = FakeClock(true) + private val shownCues = mutableListOf>() + private val playbackStates = mutableListOf() + 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) { + 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() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d1d45b2..d0d256f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }