From 21187402ab8f5257d63be9cf6ae5562ffd2e87c6 Mon Sep 17 00:00:00 2001 From: Aerya Date: Mon, 17 Aug 2026 13:59:45 +0200 Subject: [PATCH 1/2] fix(player): finalize playback before next-episode navigation --- .../tv/ui/screens/player/PlayerScreen.kt | 48 +++++++++++++++++-- .../tv/ui/screens/player/PlayerViewModel.kt | 11 +++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index dd3481279..d34c52b25 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -356,6 +356,8 @@ fun PlayerScreen( var currentPosition by remember { mutableLongStateOf(0L) } var duration by remember { mutableLongStateOf(0L) } var progress by remember { mutableFloatStateOf(0f) } + var currentPlaybackState by remember { mutableIntStateOf(Player.STATE_IDLE) } + var nextEpisodeTransitionInProgress by remember { mutableStateOf(false) } // Skip overlay state - shows +10/-10 without showing full controls var skipAmount by remember { mutableIntStateOf(0) } @@ -407,9 +409,48 @@ fun PlayerScreen( var pendingNextBingeGroup by remember { mutableStateOf(null) } var nextEpisodePromptButton by remember { mutableIntStateOf(0) } // 0 = next, 1 = cancel val nextEpisodePromptGate = remember { NextEpisodePromptGate() } + + val playNextEpisode: (Int, Int, String?, String?, String?) -> Unit = + { nextSeason, nextEpisode, nextAddonId, nextSourceName, nextBingeGroup -> + if (!nextEpisodeTransitionInProgress) { + nextEpisodeTransitionInProgress = true + + val positionSnapshot = currentPosition + val durationSnapshot = duration + val playbackStateSnapshot = currentPlaybackState + val progressPercentSnapshot = if (durationSnapshot > 0L) { + ((positionSnapshot.toDouble() / durationSnapshot.toDouble()) * 100.0) + .toInt() + .coerceIn(0, 100) + } else { + 0 + } + + coroutineScope.launch { + runCatching { + viewModel.saveProgressAndWait( + position = positionSnapshot, + duration = durationSnapshot, + progressPercent = progressPercentSnapshot, + isPlaying = false, + playbackState = playbackStateSnapshot + ) + } + + onPlayNext( + nextSeason, + nextEpisode, + nextAddonId, + nextSourceName, + nextBingeGroup + ) + } + } + } + val playPendingNextEpisode: () -> Unit = { showNextEpisodePrompt = false - onPlayNext( + playNextEpisode( pendingNextSeason, pendingNextEpisode, pendingNextAddonId, @@ -994,6 +1035,7 @@ fun PlayerScreen( // Add error listener to try next stream on codec errors addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { + currentPlaybackState = playbackState val stateStr = when (playbackState) { Player.STATE_IDLE -> "IDLE" Player.STATE_BUFFERING -> "BUFFERING" @@ -2610,7 +2652,7 @@ fun PlayerScreen( // current episode. No-op for movies (there is no next). if (mediaType == MediaType.TV && seasonNumber != null && episodeNumber != null) { val selected = uiState.selectedStream - onPlayNext( + playNextEpisode( seasonNumber, episodeNumber + 1, selected?.addonId?.takeIf { it.isNotBlank() }, @@ -3612,7 +3654,7 @@ fun PlayerScreen( val season = seasonNumber ?: return@PlayerIconButton val episode = episodeNumber ?: return@PlayerIconButton val selected = uiState.selectedStream - onPlayNext(season, episode + 1, selected?.addonId?.takeIf { it.isNotBlank() }, selected?.source?.takeIf { it.isNotBlank() }, selected?.behaviorHints?.bingeGroup?.takeIf { it.isNotBlank() }) + playNextEpisode(season, episode + 1, selected?.addonId?.takeIf { it.isNotBlank() }, selected?.source?.takeIf { it.isNotBlank() }, selected?.behaviorHints?.bingeGroup?.takeIf { it.isNotBlank() }) }, onLeftKey = { aspectButtonFocusRequester.requestFocus() }, onRightKey = { subtitleButtonFocusRequester.requestFocus() }, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index f06ef4138..0219671e9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -4160,6 +4160,17 @@ class PlayerViewModel @Inject constructor( } } + suspend fun saveProgressAndWait( + position: Long, + duration: Long, + progressPercent: Int, + isPlaying: Boolean, + playbackState: Int + ) { + saveProgress(position, duration, progressPercent, isPlaying, playbackState) + progressSaveJob?.join() + } + private var progressSaveJob: Job? = null private var subtitleRefreshJob: Job? = null private var vodAppendJob: Job? = null From 1a5761f99c85500baf4f3283d2d51d391a8e5132 Mon Sep 17 00:00:00 2001 From: Aerya Date: Tue, 18 Aug 2026 15:37:18 +0200 Subject: [PATCH 2/2] fix(player): close next-episode lifecycle races --- .../tv/ui/screens/player/PlayerScreen.kt | 32 +++++++------- .../tv/ui/screens/player/PlayerViewModel.kt | 44 ++++++++++++++----- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index d34c52b25..e306796bd 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -2413,22 +2413,24 @@ fun PlayerScreen( controlsSeekJob?.cancel() playerReleasedAtomic.set(true) playerReleased = true - runCatching { - val safeDuration = exoPlayer.duration.takeIf { it > 0L && it != C.TIME_UNSET } ?: 0L - val safeProgressPercent = if (safeDuration > 0L) { - ((exoPlayer.currentPosition.toDouble() / safeDuration.toDouble()) * 100.0) - .toInt() - .coerceIn(0, 100) - } else { - 0 + if (!nextEpisodeTransitionInProgress) { + runCatching { + val safeDuration = exoPlayer.duration.takeIf { it > 0L && it != C.TIME_UNSET } ?: 0L + val safeProgressPercent = if (safeDuration > 0L) { + ((exoPlayer.currentPosition.toDouble() / safeDuration.toDouble()) * 100.0) + .toInt() + .coerceIn(0, 100) + } else { + 0 + } + viewModel.saveProgress( + exoPlayer.currentPosition, + safeDuration, + safeProgressPercent, + isPlaying = exoPlayer.isPlaying, + playbackState = exoPlayer.playbackState + ) } - viewModel.saveProgress( - exoPlayer.currentPosition, - safeDuration, - safeProgressPercent, - isPlaying = exoPlayer.isPlaying, - playbackState = exoPlayer.playbackState - ) } runCatching { exoPlayer.release() } // Restore the system stream volume if the player left it at zero. diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 0219671e9..880cea5e6 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -3911,17 +3911,24 @@ class PlayerViewModel @Inject constructor( } } - fun saveProgress(position: Long, duration: Long, progressPercent: Int, isPlaying: Boolean, playbackState: Int) { - if (duration <= 0) return + fun saveProgress( + position: Long, + duration: Long, + progressPercent: Int, + isPlaying: Boolean, + playbackState: Int + ): Job? { + if (duration <= 0) return null - // On pause/stop, always save (cancel any in-flight periodic save). - // During playback, skip if a previous save is still running (debounce). + // On pause/stop, replace an in-flight periodic save. During normal playback, + // debounce by returning the save that is already running. if (!isPlaying || playbackState == Player.STATE_ENDED) { progressSaveJob?.cancel() } else if (progressSaveJob?.isActive == true) { - return + return progressSaveJob } - progressSaveJob = viewModelScope.launch(Dispatchers.IO) { + + val job = viewModelScope.launch(Dispatchers.IO) { val currentTime = System.currentTimeMillis() val progressFraction = (progressPercent / 100f).coerceIn(0f, 1f) val selectedStream = _uiState.value.selectedStream @@ -4155,9 +4162,15 @@ class PlayerViewModel @Inject constructor( } lastIsPlaying = isPlaying - }.also { job -> - job.invokeOnCompletion { progressSaveJob = null } } + + progressSaveJob = job + job.invokeOnCompletion { + if (progressSaveJob === job) { + progressSaveJob = null + } + } + return job } suspend fun saveProgressAndWait( @@ -4167,8 +4180,19 @@ class PlayerViewModel @Inject constructor( isPlaying: Boolean, playbackState: Int ) { - saveProgress(position, duration, progressPercent, isPlaying, playbackState) - progressSaveJob?.join() + // Let an in-flight save finish before starting the final transition save. + // This avoids cancelling after hasMarkedWatched was set but before the + // corresponding remote/history writes completed. + progressSaveJob?.takeIf { it.isActive }?.join() + + val finalJob = saveProgress( + position = position, + duration = duration, + progressPercent = progressPercent, + isPlaying = isPlaying, + playbackState = playbackState + ) + finalJob?.join() } private var progressSaveJob: Job? = null