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