improve player responsiveness by isolating timeline updates
This commit is contained in:
parent
e96ef17ef8
commit
bf910bdcc0
9 changed files with 155 additions and 50 deletions
|
|
@ -198,6 +198,25 @@ class PlayerRuntimeController(
|
|||
)
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
|
||||
|
||||
internal val _playbackTimeline = MutableStateFlow(PlaybackTimelineState())
|
||||
val playbackTimeline: StateFlow<PlaybackTimelineState> = _playbackTimeline.asStateFlow()
|
||||
|
||||
internal fun updatePlaybackTimeline(
|
||||
currentPosition: Long = _playbackTimeline.value.currentPosition,
|
||||
duration: Long = _playbackTimeline.value.duration
|
||||
) {
|
||||
_playbackTimeline.update {
|
||||
it.copy(
|
||||
currentPosition = currentPosition.coerceAtLeast(0L),
|
||||
duration = duration.coerceAtLeast(0L)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resetPlaybackTimeline() {
|
||||
_playbackTimeline.value = PlaybackTimelineState()
|
||||
}
|
||||
|
||||
internal var _exoPlayer: ExoPlayer? = null
|
||||
val exoPlayer: ExoPlayer?
|
||||
get() = _exoPlayer
|
||||
|
|
|
|||
|
|
@ -127,7 +127,8 @@ internal fun PlayerRuntimeController.switchInternalPlayerEngineManually() {
|
|||
|
||||
private fun PlayerRuntimeController.isStartupPhaseForEngineFailover(): Boolean {
|
||||
val state = _uiState.value
|
||||
return !hasRenderedFirstFrame && (state.showLoadingOverlay || state.isBuffering || state.currentPosition <= 0L)
|
||||
val currentPosition = currentPlaybackPositionMs()?.coerceAtLeast(0L) ?: playbackTimeline.value.currentPosition
|
||||
return !hasRenderedFirstFrame && (state.showLoadingOverlay || state.isBuffering || currentPosition <= 0L)
|
||||
}
|
||||
|
||||
private fun PlayerRuntimeController.targetEngineLabel(targetEngine: InternalPlayerEngine): String {
|
||||
|
|
|
|||
|
|
@ -345,11 +345,11 @@ internal fun PlayerRuntimeController.initializePlayer(
|
|||
lastKnownDuration = playerDuration
|
||||
}
|
||||
val isBuffering = playbackState == Player.STATE_BUFFERING
|
||||
updatePlaybackTimeline(duration = playerDuration.coerceAtLeast(0L))
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isBuffering = isBuffering,
|
||||
playbackEnded = playbackState == Player.STATE_ENDED,
|
||||
duration = playerDuration.coerceAtLeast(0L)
|
||||
playbackEnded = playbackState == Player.STATE_ENDED
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ internal fun PlayerRuntimeController.releasePlayer(flushPlaybackState: Boolean)
|
|||
}
|
||||
_exoPlayer = null
|
||||
playbackSpeedAwareAudioOutputProvider = null
|
||||
resetPlaybackTimeline()
|
||||
isReleasingPlayer = false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,12 +58,14 @@ internal fun PlayerRuntimeController.startProgressUpdates() {
|
|||
lastKnownDuration = playerDuration
|
||||
}
|
||||
val displayPosition = pendingPreviewSeekPosition ?: pos
|
||||
updatePlaybackTimeline(
|
||||
currentPosition = displayPosition,
|
||||
duration = playerDuration
|
||||
)
|
||||
val ended = playerDuration > 0L && pos >= (playerDuration - 500L)
|
||||
val wasEnded = _uiState.value.playbackEnded
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
currentPosition = displayPosition,
|
||||
duration = playerDuration,
|
||||
isPlaying = playingNow,
|
||||
isBuffering = !firstFrameReady || cacheBuffering,
|
||||
showLoadingOverlay = if (state.loadingOverlayEnabled) !firstFrameReady else false,
|
||||
|
|
@ -97,12 +99,10 @@ internal fun PlayerRuntimeController.startProgressUpdates() {
|
|||
lastKnownDuration = playerDuration
|
||||
}
|
||||
val displayPosition = pendingPreviewSeekPosition ?: pos
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentPosition = displayPosition,
|
||||
duration = playerDuration.coerceAtLeast(0L)
|
||||
)
|
||||
}
|
||||
updatePlaybackTimeline(
|
||||
currentPosition = displayPosition,
|
||||
duration = playerDuration.coerceAtLeast(0L)
|
||||
)
|
||||
// Update torrent rebuffer progress from ExoPlayer's buffer state
|
||||
if (isTorrentStream && _uiState.value.isBuffering && hasRenderedFirstFrame) {
|
||||
val bufferedAheadMs = (player.bufferedPosition - pos).coerceAtLeast(0)
|
||||
|
|
@ -560,7 +560,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
|
|||
.coerceAtLeast(0L)
|
||||
.coerceAtMost(maxDuration)
|
||||
seekPlaybackTo(target)
|
||||
_uiState.update { it.copy(currentPosition = target) }
|
||||
updatePlaybackTimeline(currentPosition = target)
|
||||
scheduleProgressSyncAfterSeek()
|
||||
if (_uiState.value.showControls) {
|
||||
showControlsTemporarily()
|
||||
|
|
@ -575,7 +575,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
|
|||
.coerceAtLeast(0L)
|
||||
.coerceAtMost(maxDuration)
|
||||
pendingPreviewSeekPosition = target
|
||||
_uiState.update { it.copy(currentPosition = target) }
|
||||
updatePlaybackTimeline(currentPosition = target)
|
||||
if (_uiState.value.showControls) {
|
||||
showControlsTemporarily()
|
||||
} else {
|
||||
|
|
@ -586,7 +586,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
|
|||
val target = pendingPreviewSeekPosition
|
||||
if (target != null) {
|
||||
seekPlaybackTo(target)
|
||||
_uiState.update { it.copy(currentPosition = target) }
|
||||
updatePlaybackTimeline(currentPosition = target)
|
||||
pendingPreviewSeekPosition = null
|
||||
scheduleProgressSyncAfterSeek()
|
||||
if (_uiState.value.showControls) {
|
||||
|
|
@ -599,7 +599,7 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
|
|||
is PlayerEvent.OnSeekTo -> {
|
||||
pendingPreviewSeekPosition = null
|
||||
seekPlaybackTo(event.position)
|
||||
_uiState.update { it.copy(currentPosition = event.position) }
|
||||
updatePlaybackTimeline(currentPosition = event.position)
|
||||
scheduleProgressSyncAfterSeek()
|
||||
if (_uiState.value.showControls) {
|
||||
showControlsTemporarily()
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ internal fun PlayerRuntimeController.dismissSubtitleTimingDialog() {
|
|||
}
|
||||
|
||||
internal fun PlayerRuntimeController.captureSubtitleAutoSyncTime() {
|
||||
val capturePositionMs = _exoPlayer?.currentPosition ?: _uiState.value.currentPosition
|
||||
val capturePositionMs = currentPlaybackPositionMs()?.coerceAtLeast(0L) ?: 0L
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
subtitleAutoSyncCapturedVideoMs = capturePositionMs,
|
||||
|
|
@ -60,7 +60,7 @@ internal fun PlayerRuntimeController.captureSubtitleAutoSyncTime() {
|
|||
|
||||
internal fun PlayerRuntimeController.applySubtitleAutoSyncCue(cueStartTimeMs: Long) {
|
||||
val capturePositionMs =
|
||||
_uiState.value.subtitleAutoSyncCapturedVideoMs ?: _exoPlayer?.currentPosition ?: return
|
||||
_uiState.value.subtitleAutoSyncCapturedVideoMs ?: currentPlaybackPositionMs() ?: return
|
||||
val newDelayMs = (capturePositionMs - cueStartTimeMs - AUTO_SYNC_REACTION_COMPENSATION_MS)
|
||||
.toInt()
|
||||
.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ import com.nuvio.tv.core.player.ExternalPlayerLauncher
|
|||
import com.nuvio.tv.data.local.InternalPlayerEngine
|
||||
import com.nuvio.tv.data.local.SubtitleStyleSettings
|
||||
import com.nuvio.tv.data.local.StreamAutoPlayMode
|
||||
import com.nuvio.tv.domain.model.Subtitle
|
||||
import com.nuvio.tv.ui.components.LoadingIndicator
|
||||
import com.nuvio.tv.ui.theme.NuvioColors
|
||||
import android.text.format.DateFormat
|
||||
|
|
@ -840,10 +841,7 @@ fun PlayerScreen(
|
|||
.padding(end = 28.dp, top = 24.dp)
|
||||
.zIndex(2.15f)
|
||||
) {
|
||||
PlayerClockOverlay(
|
||||
currentPosition = uiState.currentPosition,
|
||||
duration = uiState.duration
|
||||
)
|
||||
PlayerClockOverlayHost(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Controls overlay
|
||||
|
|
@ -994,7 +992,7 @@ fun PlayerScreen(
|
|||
exit = fadeOut(animationSpec = tween(150)),
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
) {
|
||||
SeekOverlay(uiState = uiState)
|
||||
SeekOverlayHost(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Episodes/streams side panel (slides in from right)
|
||||
|
|
@ -1174,9 +1172,9 @@ fun PlayerScreen(
|
|||
captureKeys = false,
|
||||
contentPadding = PaddingValues(top = 44.dp)
|
||||
) {
|
||||
SubtitleTimingDialog(
|
||||
SubtitleTimingDialogHost(
|
||||
viewModel = viewModel,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
currentPositionMs = uiState.currentPosition,
|
||||
selectedAddonSubtitle = uiState.selectedAddonSubtitle,
|
||||
cues = uiState.subtitleAutoSyncCues,
|
||||
capturedVideoMs = uiState.subtitleAutoSyncCapturedVideoMs,
|
||||
|
|
@ -1412,21 +1410,14 @@ private fun PlayerControlsOverlay(
|
|||
|
||||
// Progress bar — always LTR regardless of locale
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
ProgressBar(
|
||||
currentPosition = uiState.pendingPreviewSeekPosition ?: uiState.currentPosition,
|
||||
duration = uiState.duration,
|
||||
onSeekPreview = { delta ->
|
||||
viewModel.onEvent(PlayerEvent.OnPreviewSeekBy(delta))
|
||||
},
|
||||
onSeekCommit = {
|
||||
viewModel.onEvent(PlayerEvent.OnCommitPreviewSeek)
|
||||
},
|
||||
focusRequester = progressBarFocusRequester,
|
||||
upFocusRequester = progressBarUpFocusRequester,
|
||||
downFocusRequester = playPauseFocusRequester,
|
||||
onUpKey = onHideControls,
|
||||
onFocused = onResetHideTimer
|
||||
)
|
||||
PlayerControlsProgressBarHost(
|
||||
viewModel = viewModel,
|
||||
focusRequester = progressBarFocusRequester,
|
||||
upFocusRequester = progressBarUpFocusRequester,
|
||||
downFocusRequester = playPauseFocusRequester,
|
||||
onUpKey = onHideControls,
|
||||
onFocused = onResetHideTimer
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
|
@ -1600,17 +1591,52 @@ private fun PlayerControlsOverlay(
|
|||
}
|
||||
|
||||
// Right side - Time display only
|
||||
Text(
|
||||
text = "${formatTime(uiState.currentPosition)} / ${formatTime(uiState.duration)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
PlayerControlsTimeTextHost(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerControlsProgressBarHost(
|
||||
viewModel: PlayerViewModel,
|
||||
focusRequester: FocusRequester,
|
||||
upFocusRequester: FocusRequester? = null,
|
||||
downFocusRequester: FocusRequester? = null,
|
||||
onUpKey: (() -> Unit)? = null,
|
||||
onFocused: (() -> Unit)? = null
|
||||
) {
|
||||
val playbackTimeline by viewModel.playbackTimeline.collectAsState()
|
||||
|
||||
ProgressBar(
|
||||
currentPosition = playbackTimeline.currentPosition,
|
||||
duration = playbackTimeline.duration,
|
||||
onSeekPreview = { delta ->
|
||||
viewModel.onEvent(PlayerEvent.OnPreviewSeekBy(delta))
|
||||
},
|
||||
onSeekCommit = {
|
||||
viewModel.onEvent(PlayerEvent.OnCommitPreviewSeek)
|
||||
},
|
||||
focusRequester = focusRequester,
|
||||
upFocusRequester = upFocusRequester,
|
||||
downFocusRequester = downFocusRequester,
|
||||
onUpKey = onUpKey,
|
||||
onFocused = onFocused
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerControlsTimeTextHost(viewModel: PlayerViewModel) {
|
||||
val playbackTimeline by viewModel.playbackTimeline.collectAsState()
|
||||
|
||||
Text(
|
||||
text = "${formatTime(playbackTimeline.currentPosition)} / ${formatTime(playbackTimeline.duration)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlButton(
|
||||
icon: ImageVector,
|
||||
|
|
@ -1803,7 +1829,10 @@ private fun ProgressBar(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SeekOverlay(uiState: PlayerUiState) {
|
||||
private fun SeekOverlay(
|
||||
currentPosition: Long,
|
||||
duration: Long
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -1811,8 +1840,8 @@ private fun SeekOverlay(uiState: PlayerUiState) {
|
|||
) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
ProgressBar(
|
||||
currentPosition = uiState.currentPosition,
|
||||
duration = uiState.duration,
|
||||
currentPosition = currentPosition,
|
||||
duration = duration,
|
||||
onSeekPreview = {},
|
||||
onSeekCommit = {}
|
||||
)
|
||||
|
|
@ -1826,7 +1855,7 @@ private fun SeekOverlay(uiState: PlayerUiState) {
|
|||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "${formatTime(uiState.currentPosition)} / ${formatTime(uiState.duration)}",
|
||||
text = "${formatTime(currentPosition)} / ${formatTime(duration)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.9f)
|
||||
)
|
||||
|
|
@ -1834,6 +1863,16 @@ private fun SeekOverlay(uiState: PlayerUiState) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeekOverlayHost(viewModel: PlayerViewModel) {
|
||||
val playbackTimeline by viewModel.playbackTimeline.collectAsState()
|
||||
|
||||
SeekOverlay(
|
||||
currentPosition = playbackTimeline.currentPosition,
|
||||
duration = playbackTimeline.duration
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerClockOverlay(
|
||||
currentPosition: Long,
|
||||
|
|
@ -1880,6 +1919,45 @@ private fun PlayerClockOverlay(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerClockOverlayHost(viewModel: PlayerViewModel) {
|
||||
val playbackTimeline by viewModel.playbackTimeline.collectAsState()
|
||||
|
||||
PlayerClockOverlay(
|
||||
currentPosition = playbackTimeline.currentPosition,
|
||||
duration = playbackTimeline.duration
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubtitleTimingDialogHost(
|
||||
viewModel: PlayerViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
selectedAddonSubtitle: Subtitle?,
|
||||
cues: List<SubtitleSyncCue>,
|
||||
capturedVideoMs: Long?,
|
||||
statusMessage: String?,
|
||||
errorMessage: String?,
|
||||
isLoadingCues: Boolean,
|
||||
onCaptureNow: () -> Unit,
|
||||
onCueSelected: (SubtitleSyncCue) -> Unit
|
||||
) {
|
||||
val playbackTimeline by viewModel.playbackTimeline.collectAsState()
|
||||
|
||||
SubtitleTimingDialog(
|
||||
modifier = modifier,
|
||||
currentPositionMs = playbackTimeline.currentPosition,
|
||||
selectedAddonSubtitle = selectedAddonSubtitle,
|
||||
cues = cues,
|
||||
capturedVideoMs = capturedVideoMs,
|
||||
statusMessage = statusMessage,
|
||||
errorMessage = errorMessage,
|
||||
isLoadingCues = isLoadingCues,
|
||||
onCaptureNow = onCaptureNow,
|
||||
onCueSelected = onCueSelected
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AspectRatioIndicator(text: String) {
|
||||
val customAspectPainter = rememberRawSvgPainter(R.raw.ic_player_aspect_ratio)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ data class PlayerUiState(
|
|||
val isPlaying: Boolean = false,
|
||||
val isBuffering: Boolean = true,
|
||||
val playbackEnded: Boolean = false,
|
||||
val currentPosition: Long = 0L,
|
||||
val duration: Long = 0L,
|
||||
val title: String = "",
|
||||
val contentName: String? = null, // Series/show name (for series content)
|
||||
val releaseYear: String? = null, // Release year for movies
|
||||
|
|
@ -163,6 +161,11 @@ data class PlayerUiState(
|
|||
val hideTorrentStats: Boolean = true
|
||||
)
|
||||
|
||||
data class PlaybackTimelineState(
|
||||
val currentPosition: Long = 0L,
|
||||
val duration: Long = 0L
|
||||
)
|
||||
|
||||
data class TrackInfo(
|
||||
val index: Int,
|
||||
val name: String,
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ class PlayerViewModel @Inject constructor(
|
|||
val uiState: StateFlow<PlayerUiState>
|
||||
get() = controller.uiState
|
||||
|
||||
val playbackTimeline: StateFlow<PlaybackTimelineState>
|
||||
get() = controller.playbackTimeline
|
||||
|
||||
val exoPlayer: ExoPlayer?
|
||||
get() = controller.exoPlayer
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue