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 + } +}