diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt index ff91de1f..f86bca17 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeController.kt @@ -198,6 +198,25 @@ class PlayerRuntimeController( ) val uiState: StateFlow = _uiState.asStateFlow() + internal val _playbackTimeline = MutableStateFlow(PlaybackTimelineState()) + val playbackTimeline: StateFlow = _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 diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerEngineFailover.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerEngineFailover.kt index 62c97bd7..4f4320f7 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerEngineFailover.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerEngineFailover.kt @@ -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 { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt index 9be6bce0..6770ada7 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerInitialization.kt @@ -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 ) } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt index e57176a6..954145af 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerLifecycle.kt @@ -51,6 +51,7 @@ internal fun PlayerRuntimeController.releasePlayer(flushPlaybackState: Boolean) } _exoPlayer = null playbackSpeedAwareAudioOutputProvider = null + resetPlaybackTimeline() isReleasingPlayer = false } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt index c2a4559b..5b173764 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerPlaybackEvents.kt @@ -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() diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerSubtitleTiming.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerSubtitleTiming.kt index 7f7e8e52..e9635d3c 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerSubtitleTiming.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerRuntimeControllerSubtitleTiming.kt @@ -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) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt index c2531c59..3540cfae 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt @@ -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, + 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) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt index f68a5b13..053c4ee8 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt @@ -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, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt index aac100ce..22c37064 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt @@ -81,6 +81,9 @@ class PlayerViewModel @Inject constructor( val uiState: StateFlow get() = controller.uiState + val playbackTimeline: StateFlow + get() = controller.playbackTimeline + val exoPlayer: ExoPlayer? get() = controller.exoPlayer