From 0c9bffc3ab912af5561f592253c4b0b28aeb76d3 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 00:41:26 +0100 Subject: [PATCH 1/3] Add a subtitle delay renderer wrapper for ExoPlayer --- .../services/SubtitleDelayRenderer.kt | 216 ++++++++++++++++++ .../services/SubtitleDelayRendererTest.kt | 165 +++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/SubtitleDelayRenderer.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayRendererTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SubtitleDelayRenderer.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SubtitleDelayRenderer.kt new file mode 100644 index 00000000..88feaae1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SubtitleDelayRenderer.kt @@ -0,0 +1,216 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.services + +import android.os.Handler +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.Timeline +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ForwardingRenderer +import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.RendererConfiguration +import androidx.media3.exoplayer.RenderersFactory +import androidx.media3.exoplayer.audio.AudioRendererEventListener +import androidx.media3.exoplayer.metadata.MetadataOutput +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.SampleStream +import androidx.media3.exoplayer.text.TextOutput +import androidx.media3.exoplayer.video.VideoRendererEventListener +import io.github.peerless2012.ass.media.render.AssRenderer + +/** + * Delays the subtitles of the wrapped renderer by shifting the playback position it is given. + * + * A positive delay shows subtitles later, a negative delay earlier, the same as mpv's `sub-delay`. + * The delay is set on the playback thread with a [MSG_SET_SUBTITLE_DELAY] message whose payload is + * the delay in microseconds as a [Long]. + * + * Wrapping hides the renderer's type from ExoPlayer's `instanceof TextRenderer` checks, which only + * matter for clipped streams and server-side inserted ads. + */ +class SubtitleDelayRenderer( + internal val delegate: Renderer, +) : ForwardingRenderer(delegate) { + private val period = Timeline.Period() + private var delayUs = 0L + private var timeline: Timeline = Timeline.EMPTY + private var mediaPeriodId: MediaSource.MediaPeriodId? = null + private var streamOffsetUs = 0L + private var periodEndPositionUs = C.TIME_UNSET + private var lastPositionUs = C.TIME_UNSET + + override fun handleMessage( + messageType: Int, + message: Any?, + ) { + if (messageType == MSG_SET_SUBTITLE_DELAY) { + delayUs = message as Long + } else { + super.handleMessage(messageType, message) + } + } + + override fun enable( + configuration: RendererConfiguration, + formats: Array, + stream: SampleStream, + positionUs: Long, + joining: Boolean, + mayRenderStartOfStream: Boolean, + startPositionUs: Long, + offsetUs: Long, + mediaPeriodId: MediaSource.MediaPeriodId, + ) { + lastPositionUs = positionUs + updateStream(offsetUs, mediaPeriodId) + super.enable( + configuration, + formats, + stream, + positionUs - delayUs, + joining, + mayRenderStartOfStream, + startPositionUs, + offsetUs, + mediaPeriodId, + ) + } + + override fun replaceStream( + formats: Array, + stream: SampleStream, + startPositionUs: Long, + offsetUs: Long, + mediaPeriodId: MediaSource.MediaPeriodId, + ) { + updateStream(offsetUs, mediaPeriodId) + super.replaceStream(formats, stream, startPositionUs, offsetUs, mediaPeriodId) + } + + override fun setTimeline(timeline: Timeline) { + this.timeline = timeline + updatePeriodEndPosition() + super.setTimeline(timeline) + } + + override fun render( + positionUs: Long, + elapsedRealtimeUs: Long, + ) { + lastPositionUs = positionUs + super.render(positionUs - delayUs, elapsedRealtimeUs) + } + + override fun resetPosition( + positionUs: Long, + sampleStreamIsResetToKeyFrame: Boolean, + ) { + lastPositionUs = positionUs + super.resetPosition(positionUs - delayUs, sampleStreamIsResetToKeyFrame) + } + + override fun supportsResetPositionWithoutKeyFrameReset(positionUs: Long): Boolean = + super.supportsResetPositionWithoutKeyFrameReset(positionUs - delayUs) + + override fun getDurationToProgressUs( + positionUs: Long, + elapsedRealtimeUs: Long, + ): Long = super.getDurationToProgressUs(positionUs - delayUs, elapsedRealtimeUs) + + /** + * A text renderer only reads the end of its stream once its position is within a second of the + * period end, so a positive delay above a second would hold back + * [androidx.media3.common.Player.STATE_ENDED] by the delay minus that second. Once the real + * position passes the period end there is nothing left to show, so the renderer counts as ended. + */ + override fun isEnded(): Boolean = + super.isEnded() || + ( + delayUs > 0 && + periodEndPositionUs != C.TIME_UNSET && + lastPositionUs >= periodEndPositionUs + ) + + override fun disable() { + mediaPeriodId = null + periodEndPositionUs = C.TIME_UNSET + super.disable() + } + + private fun updateStream( + offsetUs: Long, + mediaPeriodId: MediaSource.MediaPeriodId, + ) { + streamOffsetUs = offsetUs + this.mediaPeriodId = mediaPeriodId + updatePeriodEndPosition() + } + + private fun updatePeriodEndPosition() { + val periodIndex = mediaPeriodId?.let { timeline.getIndexOfPeriod(it.periodUid) } ?: C.INDEX_UNSET + val durationUs = + if (periodIndex == C.INDEX_UNSET) { + C.TIME_UNSET + } else { + timeline.getPeriod(periodIndex, period).durationUs + } + periodEndPositionUs = if (durationUs == C.TIME_UNSET) C.TIME_UNSET else streamOffsetUs + durationUs + } + + companion object { + /** Sets the subtitle delay; the payload is the delay in microseconds as a [Long]. */ + const val MSG_SET_SUBTITLE_DELAY = Renderer.MSG_CUSTOM_BASE + } +} + +/** + * Wraps the subtitle renderers of [delegate] in a [SubtitleDelayRenderer]: media3's text renderer + * and the libass [AssRenderer], which draws at the position it is rendered with. + */ +class SubtitleDelayRenderersFactory( + private val delegate: RenderersFactory, +) : RenderersFactory { + override fun createRenderers( + eventHandler: Handler, + videoRendererEventListener: VideoRendererEventListener, + audioRendererEventListener: AudioRendererEventListener, + textRendererOutput: TextOutput, + metadataRendererOutput: MetadataOutput, + ): Array { + val renderers = + delegate.createRenderers( + eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput, + ) + return Array(renderers.size) { index -> + val renderer = renderers[index] + if (renderer.trackType == C.TRACK_TYPE_TEXT || renderer is AssRenderer) { + SubtitleDelayRenderer(renderer) + } else { + renderer + } + } + } + + override fun createSecondaryRenderer( + renderer: Renderer, + eventHandler: Handler, + videoRendererEventListener: VideoRendererEventListener, + audioRendererEventListener: AudioRendererEventListener, + textRendererOutput: TextOutput, + metadataRendererOutput: MetadataOutput, + ): Renderer? = + delegate.createSecondaryRenderer( + (renderer as? SubtitleDelayRenderer)?.delegate ?: renderer, + eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput, + ) +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayRendererTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayRendererTest.kt new file mode 100644 index 00000000..af8464e8 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayRendererTest.kt @@ -0,0 +1,165 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.services + +import android.os.Handler +import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.RendererConfiguration +import androidx.media3.exoplayer.RenderersFactory +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.SinglePeriodTimeline +import io.github.peerless2012.ass.media.AssHandler +import io.github.peerless2012.ass.media.factory.AssRenderersFactory +import io.github.peerless2012.ass.media.render.AssRenderer +import io.github.peerless2012.ass.media.type.AssRenderType +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class SubtitleDelayRendererTest { + private val delegate = mockk(relaxed = true) + private val renderer = SubtitleDelayRenderer(delegate) + private val timeline = SinglePeriodTimeline(DURATION_US, true, false, false, null, MediaItem.EMPTY) + private val mediaPeriodId = MediaSource.MediaPeriodId(timeline.getUidOfPeriod(0)) + + @Test + fun shiftsEveryPositionByTheDelay() { + for (delayUs in listOf(2_000_000L, -500_000L)) { + val delegate = mockk(relaxed = true) + val renderer = SubtitleDelayRenderer(delegate) + renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs) + val positionUs = OFFSET_US + 5_000_000L + val shiftedUs = positionUs - delayUs + + enable(renderer, positionUs) + renderer.render(positionUs, 7L) + renderer.resetPosition(positionUs, true) + renderer.getDurationToProgressUs(positionUs, 7L) + renderer.supportsResetPositionWithoutKeyFrameReset(positionUs) + + verify { + delegate.enable(any(), any(), any(), shiftedUs, false, true, OFFSET_US, OFFSET_US, mediaPeriodId) + delegate.render(shiftedUs, 7L) + delegate.resetPosition(shiftedUs, true) + delegate.getDurationToProgressUs(shiftedUs, 7L) + delegate.supportsResetPositionWithoutKeyFrameReset(shiftedUs) + } + } + } + + @Test + fun delayMessageChangesTheShiftAndOtherMessagesPassThrough() { + renderer.render(10_000_000L, 0L) + renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 250_000L) + renderer.render(10_000_000L, 0L) + renderer.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f) + + verify { + delegate.render(10_000_000L, 0L) + delegate.render(9_750_000L, 0L) + delegate.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f) + } + verify(exactly = 0) { delegate.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, any()) } + } + + @Test + fun endsAtThePeriodEndWithAPositiveDelay() { + every { delegate.isEnded } returns false + enable(renderer, OFFSET_US) + renderer.setTimeline(timeline) + renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L) + + renderer.render(OFFSET_US + DURATION_US - 1, 0L) + assertFalse(renderer.isEnded) + + renderer.render(OFFSET_US + DURATION_US, 0L) + assertTrue(renderer.isEnded) + } + + @Test + fun otherwiseMirrorsTheDelegateAtThePeriodEnd() { + enable(renderer, OFFSET_US) + renderer.setTimeline(timeline) + for (delayUs in listOf(0L, -2_000_000L)) { + renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs) + renderer.render(OFFSET_US + DURATION_US, 0L) + for (delegateEnded in listOf(false, true)) { + every { delegate.isEnded } returns delegateEnded + assertEquals(delegateEnded, renderer.isEnded) + } + } + } + + @Test + fun doesNotEndWithoutAKnownPeriodDuration() { + every { delegate.isEnded } returns false + val unknownDuration = SinglePeriodTimeline(C.TIME_UNSET, false, false, false, null, MediaItem.EMPTY) + enable(renderer, OFFSET_US, MediaSource.MediaPeriodId(unknownDuration.getUidOfPeriod(0))) + renderer.setTimeline(unknownDuration) + renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L) + + renderer.render(OFFSET_US + DURATION_US * 10, 0L) + + assertFalse(renderer.isEnded) + } + + @Test + fun factoryWrapsOnlySubtitleRenderers() { + val video = fakeRenderer(C.TRACK_TYPE_VIDEO) + val audio = fakeRenderer(C.TRACK_TYPE_AUDIO) + val text = fakeRenderer(C.TRACK_TYPE_TEXT) + val base = + mockk { + every { createRenderers(any(), any(), any(), any(), any()) } returns arrayOf(video, audio, text) + } + val factory = + SubtitleDelayRenderersFactory(AssRenderersFactory(AssHandler(AssRenderType.OVERLAY_OPEN_GL), base)) + + val renderers = + factory.createRenderers(Handler(Looper.getMainLooper()), mockk(), mockk(), mockk(), mockk()) + + assertSame(video, renderers[0]) + assertSame(audio, renderers[1]) + assertSame(text, (renderers[2] as SubtitleDelayRenderer).delegate) + assertTrue((renderers.last() as SubtitleDelayRenderer).delegate is AssRenderer) + assertEquals(4, renderers.size) + } + + private fun enable( + renderer: Renderer, + positionUs: Long, + periodId: MediaSource.MediaPeriodId = mediaPeriodId, + ) = renderer.enable( + RendererConfiguration.DEFAULT, + arrayOf(), + mockk(), + positionUs, + false, + true, + OFFSET_US, + OFFSET_US, + periodId, + ) + + private fun fakeRenderer(trackType: Int) = mockk(relaxed = true) { every { this@mockk.trackType } returns trackType } + + private companion object { + const val OFFSET_US = 1_000_000_000_000L + const val DURATION_US = 10_000_000L + } +} From 7c03a3fe0d685a98c2a4c0ab4dfa839e6347bbfb Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 00:41:26 +0100 Subject: [PATCH 2/3] Apply the subtitle delay on ExoPlayer and show the delay control --- .../wholphin/services/PlayerFactory.kt | 26 +++- .../wholphin/ui/playback/PlaybackPage.kt | 38 ++++- .../ui/playback/SubtitleDelayControlTest.kt | 146 ++++++++++++++++++ 3 files changed, 205 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelayControlTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index a2fd0eb7..4ccdce61 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -54,6 +54,7 @@ import timber.log.Timber import java.lang.reflect.Constructor import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration /** * Constructs a [Player] instance for video playback @@ -174,7 +175,7 @@ class PlayerFactory ExoPlayer .Builder(context) .setMediaSourceFactory(mediaSourceFactory) - .setRenderersFactory(renderersFactory) + .setRenderersFactory(SubtitleDelayRenderersFactory(renderersFactory)) .setTrackSelector(trackSelector) .build() .apply { @@ -303,6 +304,29 @@ val Player.isReleased: Boolean } } +/** + * Delays the subtitles of this player by [delay]; a positive delay shows them later + */ +fun Player.applySubtitleDelay(delay: Duration) { + when (this) { + is MpvPlayer -> { + subtitleDelay = delay + } + + is ExoPlayer -> { + for (index in 0 until rendererCount) { + val renderer = getRenderer(index) + if (renderer is SubtitleDelayRenderer) { + createMessage(renderer) + .setType(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY) + .setPayload(delay.inWholeMicroseconds) + .send() + } + } + } + } +} + data class PlayerCreation( val player: Player, val assHandler: AssHandler? = null, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 86d64651..d0cdf6d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -60,7 +60,9 @@ import androidx.compose.ui.window.DialogProperties import androidx.core.view.children import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.SubtitleView import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW @@ -75,6 +77,7 @@ import com.github.damontecres.wholphin.preferences.DpadSeekMode import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume +import com.github.damontecres.wholphin.services.applySubtitleDelay import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService @@ -99,6 +102,8 @@ import com.github.damontecres.wholphin.util.Media3SubtitleOverride import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import kotlin.time.Duration @@ -185,9 +190,8 @@ fun PlaybackPageContent( var playbackSpeed by remember { mutableFloatStateOf(1.0f) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } - LaunchedEffect(state.currentPlayback?.subtitleDelay) { - (player as? MpvPlayer)?.subtitleDelay = - state.currentPlayback?.subtitleDelay ?: Duration.ZERO + LaunchedEffect(player, state.currentPlayback?.subtitleDelay) { + player.applySubtitleDelay(state.currentPlayback?.subtitleDelay ?: Duration.ZERO) } val presentationState = rememberPresentationState(player, false) @@ -784,8 +788,34 @@ fun PlaybackPageContent( }, onPlaybackActionClick = onPlaybackActionClick, onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, - enableSubtitleDelay = player is MpvPlayer, + enableSubtitleDelay = subtitleDelaySupported(player, state.currentPlayback), enableVideoScale = player !is MpvPlayer, ) } } + +/** + * Whether the subtitle delay can be changed: always with MPV, and with ExoPlayer unless the server + * burns the selected subtitles into the video + */ +fun subtitleDelaySupported( + player: Player, + currentPlayback: CurrentPlayback?, +): Boolean = + when (player) { + is MpvPlayer -> { + true + } + + is ExoPlayer -> { + val subtitleStream = + currentPlayback?.mediaSourceInfo?.mediaStreams?.firstOrNull { + it.type == MediaStreamType.SUBTITLE && it.index == currentPlayback.subtitleIndex + } + subtitleStream?.deliveryMethod != SubtitleDeliveryMethod.ENCODE + } + + else -> { + false + } + } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelayControlTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelayControlTest.kt new file mode 100644 index 00000000..44ac46c4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SubtitleDelayControlTest.kt @@ -0,0 +1,146 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.mpv.MpvPlayer +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import io.mockk.mockk +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.PlayMethod +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.nio.file.Paths +import kotlin.io.path.readText +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * The subtitle delay control shows for every player that can move the selected subtitles, and only + * for those + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class SubtitleDelayControlTest { + @get:Rule val rule = createComposeRule() + + private val subtitleDelay = ApplicationProvider.getApplicationContext().getString(R.string.subtitle_delay) + + @Test + fun supportedUnlessTheSubtitlesAreBurnedIn() { + val exoPlayer = mockk() + val cases = + listOf( + Triple(mockk(), playback(EMBEDDED_INDEX), true), + Triple(exoPlayer, playback(EMBEDDED_INDEX), true), + Triple(exoPlayer, playback(EXTERNAL_INDEX), true), + Triple(exoPlayer, playback(EMBEDDED_INDEX, burnedIn = true), false), + Triple(exoPlayer, playback(TrackIndex.DISABLED, burnedIn = true), true), + Triple(mockk(), playback(EMBEDDED_INDEX), false), + ) + for ((player, playback, expected) in cases) { + assertEquals( + "${player.javaClass.simpleName} ${playback.subtitleIndex}", + expected, + subtitleDelaySupported(player, playback), + ) + } + } + + @Test + fun settingsListTheSubtitleDelayOnlyWhenEnabled() { + val enabled = mutableStateOf(true) + rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SETTINGS, enabled.value) {} } } + rule.onNodeWithText(subtitleDelay).assertExists() + + enabled.value = false + rule.onNodeWithText(subtitleDelay).assertDoesNotExist() + } + + @Test + fun delayButtonsReportTheChange() { + val changes = mutableListOf() + rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SUBTITLE_DELAY, true) { changes.add(it) } } } + + rule.onNodeWithText("+250ms").performSemanticsAction(SemanticsActions.OnClick) + + assertEquals(listOf(250.milliseconds), changes) + } + + @Composable + private fun Dialog( + type: PlaybackDialogType, + enableSubtitleDelay: Boolean, + onChangeSubtitleDelay: (Duration) -> Unit, + ) = PlaybackDialog( + enableSubtitleDelay = enableSubtitleDelay, + enableVideoScale = true, + type = type, + settings = + PlaybackSettings( + showDebugInfo = false, + audioIndex = null, + audioStreams = listOf(), + subtitleIndex = null, + subtitleStreams = listOf(), + playbackSpeed = 1f, + contentScale = ContentScale.Fit, + subtitleDelay = Duration.ZERO, + hasSubtitleDownloadPermission = false, + playbackSpeedEnabled = true, + maxBitrate = 0L, + ), + onDismissRequest = {}, + onControllerInteraction = {}, + onClickPlaybackDialogType = {}, + onPlaybackActionClick = {}, + onChangeSubtitleDelay = onChangeSubtitleDelay, + ) + + /** + * A playback of `external_subs.json`, a PlaybackInfo response whose subtitle streams carry the + * server's delivery method, with [subtitleIndex] selected; [burnedIn] makes the server burn the + * embedded subtitles into the video + */ + private fun playback( + subtitleIndex: Int, + burnedIn: Boolean = false, + ): CurrentPlayback { + val resource = javaClass.classLoader!!.getResource("external_subs.json") + var json = Paths.get(resource.toURI()).readText() + if (burnedIn) { + json = json.replace("\"DeliveryMethod\": \"Embed\"", "\"DeliveryMethod\": \"Encode\"") + } + return CurrentPlayback( + item = mockk(), + tracks = listOf(), + backend = PlayerBackend.EXO_PLAYER, + playMethod = PlayMethod.DIRECT_PLAY, + playSessionId = null, + liveStreamId = null, + mediaSourceInfo = Json.decodeFromString(json), + subtitleIndex = subtitleIndex, + ) + } + + private companion object { + const val EXTERNAL_INDEX = 0 + const val EMBEDDED_INDEX = 5 + } +} From 0a7c059f241360bb76914ff53721237f2c4abd38 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 00:41:26 +0100 Subject: [PATCH 3/3] Add an end-to-end subtitle delay playback test --- app/build.gradle.kts | 2 + .../services/SubtitleDelayPlaybackTest.kt | 186 ++++++++++++++++++ gradle/libs.versions.toml | 2 + 3 files changed, 190 insertions(+) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/SubtitleDelayPlaybackTest.kt 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" }