Apply the subtitle delay on ExoPlayer and show the delay control

This commit is contained in:
justinzeus 2026-11-08 00:41:26 +01:00
parent 0c9bffc3ab
commit 7c03a3fe0d
3 changed files with 205 additions and 5 deletions

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

@ -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,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
}
}