improve player responsiveness by isolating timeline updates

This commit is contained in:
HALİL İBRAHİM ÖZDEMİR 2026-04-13 23:29:27 +03:00
parent e96ef17ef8
commit bf910bdcc0
9 changed files with 155 additions and 50 deletions

View file

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

View file

@ -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 {

View file

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

View file

@ -51,6 +51,7 @@ internal fun PlayerRuntimeController.releasePlayer(flushPlaybackState: Boolean)
}
_exoPlayer = null
playbackSpeedAwareAudioOutputProvider = null
resetPlaybackTimeline()
isReleasingPlayer = false
}

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

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

View file

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