mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
MPV: Adjust subtitle delay/offset (#470)
**This is for MPV playback backend only!** Since this only works in MPV for now, the option is not shown if using ExoPlayer. Adds a setting (the gear icon during playback) to adjust the subtitle delay. A negative delay shows subtitles sooner. A positive delay shows them later. For example, if a subtitle is supposed to show between 1000ms-2000ms, a negative 250ms delay will show it at 750ms-1750ms instead. The delay is persisted between plays of the same media & subtitle track. Ref: #12 
This commit is contained in:
parent
3f5ce703d2
commit
ab8bbf2bd7
12 changed files with 751 additions and 9 deletions
|
|
@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.util.TrackSupport
|
|||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.TranscodingInfo
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class CurrentPlayback(
|
||||
val item: BaseItem,
|
||||
|
|
@ -18,4 +19,5 @@ data class CurrentPlayback(
|
|||
val videoDecoder: String? = null,
|
||||
val audioDecoder: String? = null,
|
||||
val transcodeInfo: TranscodingInfo? = null,
|
||||
val subtitleDelay: Duration = Duration.ZERO,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,26 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import android.view.Gravity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration
|
||||
|
||||
enum class PlaybackDialogType {
|
||||
MORE,
|
||||
|
|
@ -16,6 +29,7 @@ enum class PlaybackDialogType {
|
|||
AUDIO,
|
||||
PLAYBACK_SPEED,
|
||||
VIDEO_SCALE,
|
||||
SUBTITLE_DELAY,
|
||||
}
|
||||
|
||||
data class PlaybackSettings(
|
||||
|
|
@ -26,16 +40,19 @@ data class PlaybackSettings(
|
|||
val subtitleStreams: List<SubtitleStream>,
|
||||
val playbackSpeed: Float,
|
||||
val contentScale: ContentScale,
|
||||
val subtitleDelay: Duration,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PlaybackDialog(
|
||||
enableSubtitleDelay: Boolean,
|
||||
type: PlaybackDialogType,
|
||||
settings: PlaybackSettings,
|
||||
onDismissRequest: () -> Unit,
|
||||
onControllerInteraction: () -> Unit,
|
||||
onClickPlaybackDialogType: (PlaybackDialogType) -> Unit,
|
||||
onPlaybackActionClick: (PlaybackAction) -> Unit,
|
||||
onChangeSubtitleDelay: (Duration) -> Unit,
|
||||
) {
|
||||
when (type) {
|
||||
PlaybackDialogType.MORE -> {
|
||||
|
|
@ -97,11 +114,14 @@ fun PlaybackDialog(
|
|||
|
||||
PlaybackDialogType.SETTINGS -> {
|
||||
val options =
|
||||
listOf(
|
||||
stringResource(R.string.audio),
|
||||
stringResource(R.string.playback_speed),
|
||||
stringResource(R.string.video_scale),
|
||||
)
|
||||
buildList {
|
||||
add(stringResource(R.string.audio))
|
||||
add(stringResource(R.string.playback_speed))
|
||||
add(stringResource(R.string.video_scale))
|
||||
if (enableSubtitleDelay) {
|
||||
add(stringResource(R.string.subtitle_delay))
|
||||
}
|
||||
}
|
||||
BottomDialog(
|
||||
choices = options,
|
||||
currentChoice = null,
|
||||
|
|
@ -111,6 +131,7 @@ fun PlaybackDialog(
|
|||
0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO)
|
||||
1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED)
|
||||
2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE)
|
||||
3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY)
|
||||
}
|
||||
},
|
||||
gravity = Gravity.END,
|
||||
|
|
@ -173,5 +194,31 @@ fun PlaybackDialog(
|
|||
gravity = Gravity.END,
|
||||
)
|
||||
}
|
||||
|
||||
PlaybackDialogType.SUBTITLE_DELAY -> {
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
|
||||
dialogWindowProvider?.window?.setDimAmount(0f)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.wrapContentSize()
|
||||
.background(
|
||||
AppColors.TransparentBlack50,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
),
|
||||
) {
|
||||
SubtitleDelay(
|
||||
delay = settings.subtitleDelay,
|
||||
onChangeDelay = onChangeSubtitleDelay,
|
||||
modifier = Modifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,11 +79,13 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.damontecres.wholphin.util.Media3SubtitleOverride
|
||||
import com.github.damontecres.wholphin.util.mpv.MpvPlayer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
|
@ -163,6 +165,11 @@ fun PlaybackPage(
|
|||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
||||
|
||||
val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO
|
||||
LaunchedEffect(subtitleDelay) {
|
||||
(player as? MpvPlayer)?.subtitleDelay = subtitleDelay
|
||||
}
|
||||
|
||||
val presentationState = rememberPresentationState(player, false)
|
||||
val scaledModifier =
|
||||
Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
||||
|
|
@ -543,6 +550,7 @@ fun PlaybackPage(
|
|||
subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(),
|
||||
playbackSpeed = playbackSpeed,
|
||||
contentScale = contentScale,
|
||||
subtitleDelay = subtitleDelay,
|
||||
),
|
||||
onDismissRequest = {
|
||||
playbackDialog = null
|
||||
|
|
@ -553,8 +561,16 @@ fun PlaybackPage(
|
|||
onControllerInteraction = {
|
||||
controllerViewState.pulseControls(Long.MAX_VALUE)
|
||||
},
|
||||
onClickPlaybackDialogType = { playbackDialog = it },
|
||||
onClickPlaybackDialogType = {
|
||||
if (it == PlaybackDialogType.SUBTITLE_DELAY) {
|
||||
// Hide controls so subtitles are fully visible
|
||||
controllerViewState.hideControls()
|
||||
}
|
||||
playbackDialog = it
|
||||
},
|
||||
onPlaybackActionClick = onPlaybackActionClick,
|
||||
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
|
||||
enableSubtitleDelay = player is MpvPlayer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -515,7 +515,7 @@ class PlaybackViewModel
|
|||
|
||||
this@PlaybackViewModel.currentItemPlayback.value = itemPlayback
|
||||
}
|
||||
|
||||
loadSubtitleDelay()
|
||||
return@withContext
|
||||
}
|
||||
} else {
|
||||
|
|
@ -686,6 +686,7 @@ class PlaybackViewModel
|
|||
if (result.bothSelected) {
|
||||
player.removeListener(this)
|
||||
}
|
||||
viewModelScope.launchIO { loadSubtitleDelay() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1150,4 +1151,47 @@ class PlaybackViewModel
|
|||
Timber.d("decoder: onAudioDisabled")
|
||||
currentPlayback.update { it?.copy(audioDecoder = null) }
|
||||
}
|
||||
|
||||
private var subtitleDelaySaveJob: Job? = null
|
||||
|
||||
fun updateSubtitleDelay(delta: Duration) {
|
||||
subtitleDelaySaveJob?.cancel()
|
||||
currentPlayback.update {
|
||||
it?.let {
|
||||
val newDelay = it.subtitleDelay + delta
|
||||
val result = it.copy(subtitleDelay = it.subtitleDelay + delta)
|
||||
subtitleDelaySaveJob =
|
||||
viewModelScope.launchIO {
|
||||
// Debounce & save
|
||||
currentItemPlayback.value?.let { item ->
|
||||
delay(1500)
|
||||
itemPlaybackRepository.saveTrackModifications(
|
||||
item.itemId,
|
||||
item.subtitleIndex,
|
||||
newDelay,
|
||||
)
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadSubtitleDelay() {
|
||||
currentItemPlayback.value?.let {
|
||||
if (it.subtitleIndexEnabled) {
|
||||
val result =
|
||||
itemPlaybackRepository.getTrackModifications(it.itemId, it.subtitleIndex)
|
||||
if (result != null) {
|
||||
Timber.v(
|
||||
"Loading subtitle delay %s for track=%s, itemId=%s",
|
||||
result.delayMs,
|
||||
it.subtitleIndex,
|
||||
it.itemId,
|
||||
)
|
||||
currentPlayback.update { it?.copy(subtitleDelay = result.delayMs.milliseconds) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.ClickableSurfaceDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
||||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
private val delayIncrements = listOf(50.milliseconds, 250.milliseconds, 1.seconds)
|
||||
|
||||
@Composable
|
||||
fun SubtitleDelay(
|
||||
delay: Duration,
|
||||
onChangeDelay: (Duration) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.subtitle_delay) + ": " + delay.toString(),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
delayIncrements.reversed().forEach {
|
||||
SubtitleDelayButton(
|
||||
text = "-$it",
|
||||
onClick = { onChangeDelay.invoke(-it) },
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
SubtitleDelayButton(
|
||||
text = stringResource(R.string.reset),
|
||||
onClick = { onChangeDelay.invoke(-delay) },
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
delayIncrements.forEach {
|
||||
SubtitleDelayButton(
|
||||
text = "+$it",
|
||||
onClick = { onChangeDelay.invoke(it) },
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SubtitleDelayButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier.width(64.dp),
|
||||
shape =
|
||||
ClickableSurfaceDefaults.shape(
|
||||
shape = RoundedCornerShape(33),
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun SubtitleDelayPreview() {
|
||||
WholphinTheme {
|
||||
SubtitleDelay(
|
||||
delay = 1.5.seconds,
|
||||
onChangeDelay = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue