Apply the subtitle delay on ExoPlayer and show the delay control
This commit is contained in:
parent
0c9bffc3ab
commit
7c03a3fe0d
3 changed files with 205 additions and 5 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue