Merge pull request 'Apply the subtitle delay on ExoPlayer' (#74) from fm/sub-offset-core into fm/tv-round2

This commit is contained in:
justinzeus 2026-11-08 01:45:20 +01:00
commit 12a81ca8e1
8 changed files with 776 additions and 5 deletions

View file

@ -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")

View file

@ -54,6 +54,7 @@ import timber.log.Timber
import java.lang.reflect.Constructor import java.lang.reflect.Constructor
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration
/** /**
* Constructs a [Player] instance for video playback * Constructs a [Player] instance for video playback
@ -174,7 +175,7 @@ class PlayerFactory
ExoPlayer ExoPlayer
.Builder(context) .Builder(context)
.setMediaSourceFactory(mediaSourceFactory) .setMediaSourceFactory(mediaSourceFactory)
.setRenderersFactory(renderersFactory) .setRenderersFactory(SubtitleDelayRenderersFactory(renderersFactory))
.setTrackSelector(trackSelector) .setTrackSelector(trackSelector)
.build() .build()
.apply { .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( data class PlayerCreation(
val player: Player, val player: Player,
val assHandler: AssHandler? = null, val assHandler: AssHandler? = null,

View file

@ -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<out Format>,
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<out Format>,
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<Renderer> {
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,
)
}

View file

@ -60,7 +60,9 @@ import androidx.compose.ui.window.DialogProperties
import androidx.core.view.children import androidx.core.view.children
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.SubtitleView import androidx.media3.ui.SubtitleView
import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.PlayerSurface
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW 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.PlayerBackend
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume 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.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService 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 io.github.peerless2012.ass.media.widget.AssSubtitleView
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch 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 org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration import kotlin.time.Duration
@ -185,9 +190,8 @@ fun PlaybackPageContent(
var playbackSpeed by remember { mutableFloatStateOf(1.0f) } var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
LaunchedEffect(state.currentPlayback?.subtitleDelay) { LaunchedEffect(player, state.currentPlayback?.subtitleDelay) {
(player as? MpvPlayer)?.subtitleDelay = player.applySubtitleDelay(state.currentPlayback?.subtitleDelay ?: Duration.ZERO)
state.currentPlayback?.subtitleDelay ?: Duration.ZERO
} }
val presentationState = rememberPresentationState(player, false) val presentationState = rememberPresentationState(player, false)
@ -784,8 +788,34 @@ fun PlaybackPageContent(
}, },
onPlaybackActionClick = onPlaybackActionClick, onPlaybackActionClick = onPlaybackActionClick,
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
enableSubtitleDelay = player is MpvPlayer, enableSubtitleDelay = subtitleDelaySupported(player, state.currentPlayback),
enableVideoScale = player !is MpvPlayer, 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
}
}

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

@ -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<Renderer>(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<Renderer>(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<RenderersFactory> {
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<Renderer>(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
}
}

View file

@ -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<Context>().getString(R.string.subtitle_delay)
@Test
fun supportedUnlessTheSubtitlesAreBurnedIn() {
val exoPlayer = mockk<ExoPlayer>()
val cases =
listOf(
Triple(mockk<MpvPlayer>(), 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<Player>(), 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<Duration>()
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<MediaSourceInfo>(json),
subtitleIndex = subtitleIndex,
)
}
private companion object {
const val EXTERNAL_INDEX = 0
const val EMBEDDED_INDEX = 5
}
}

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 = { 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" }