From 372a07da63128143124c2fde234f2871bc11a4bf Mon Sep 17 00:00:00 2001 From: Arvin Date: Sun, 5 Apr 2026 14:53:34 +0200 Subject: [PATCH] feat: post-episode Up Next prompt (#86) and honor auto-play setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a TV episode ends, PlayerScreen previously called onPlayNext() immediately with zero UI feedback — users were abruptly dropped into the next episode with no way to cancel except by hitting back. It also ignored the profile's autoPlayNext setting entirely, so users who disabled auto-advance still got silently advanced at the end of every episode. NextEpisodeOverlay has been fully implemented for a while (~260 LOC with countdown ring, focus handling, Back/Escape cancel, Enter confirm) but was never invoked anywhere — confirmed by grep. This PR finally wires it up. Changes: - Add `autoPlayNext: Boolean` to PlayerUiState, load it from DataStore at the start of loadDetails via the existing profileManager.profileBooleanKey("auto_play_next") path that SettingsViewModel already uses to persist the toggle. The player now respects the setting for the first time. - Add overlay state vars (showNextEpisodePrompt + pending next-episode metadata) to PlayerScreen. - Replace the unconditional onPlayNext() call in the STATE_ENDED handler with a state transition that shows the overlay. Gated on autoPlayNext — when disabled we stay on the ended frame instead of advancing. - Add re-entry guards: only fires once per session (showNextEpisodePrompt guard), doesn't fire while error/source/subtitle overlays are visible. - Render NextEpisodeOverlay after StreamSelector in the UI tree, wired to onPlayNext (advances) / onCancel (stays on ended frame) / 10-second countdown (auto-advances). - Include showNextEpisodePrompt in the container-focus LaunchedEffect key set so the overlay's own onKeyEvent handler receives D-pad input without the background container stealing focus. - Use a generic "Episode N" label in the overlay rather than exposing the current episode's title, since fetching the *upcoming* episode's metadata would require an extra TMDB round-trip during playback. Show title, S/E number, and backdrop give users enough context to decide. Closes #86 --- .../tv/ui/screens/player/PlayerScreen.kt | 77 ++++++++++++++++--- .../tv/ui/screens/player/PlayerViewModel.kt | 8 +- 2 files changed, 72 insertions(+), 13 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 fdfe35b07..c94ab978b 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 @@ -128,6 +128,7 @@ import com.arflix.tv.data.model.MediaType import com.arflix.tv.data.model.StreamSource import com.arflix.tv.data.model.Subtitle import com.arflix.tv.ui.components.LoadingIndicator +import com.arflix.tv.ui.components.NextEpisodeOverlay import com.arflix.tv.ui.components.StreamSelector import com.arflix.tv.ui.components.WaveLoadingDots import androidx.compose.ui.text.style.TextOverflow @@ -244,6 +245,16 @@ fun PlayerScreen( var focusedButton by remember { mutableIntStateOf(0) } var showSubtitleMenu by remember { mutableStateOf(false) } var showSourceMenu by remember { mutableStateOf(false) } + // Post-episode "Up Next" prompt (issue #86). Shown on STATE_ENDED for TV shows: + // a 10-second countdown lets the user Cancel or immediately Continue. On timeout we + // advance to the next episode. Gated on the existing autoPlayNext profile setting — + // when disabled we simply stay on the ended frame rather than advancing silently. + var showNextEpisodePrompt by remember { mutableStateOf(false) } + var pendingNextSeason by remember { mutableIntStateOf(0) } + var pendingNextEpisode by remember { mutableIntStateOf(0) } + var pendingNextAddonId by remember { mutableStateOf(null) } + var pendingNextSourceName by remember { mutableStateOf(null) } + var pendingNextBingeGroup by remember { mutableStateOf(null) } var playerResizeMode by remember { mutableIntStateOf(AspectRatioFrameLayout.RESIZE_MODE_FIT) } var subtitleMenuIndex by remember { mutableIntStateOf(0) } var subtitleMenuTab by remember { mutableIntStateOf(0) } // 0 = Subtitles, 1 = Audio @@ -1247,17 +1258,26 @@ fun PlayerScreen( } - // Auto-play next episode when current one ends - if (exoPlayer.playbackState == Player.STATE_ENDED && mediaType == MediaType.TV) { - if (seasonNumber != null && episodeNumber != null) { + // Post-episode prompt: when a TV episode ends, show the "Up Next" overlay with a + // 10-second countdown that auto-advances (or lets the user cancel / continue + // immediately). Gated on the profile's autoPlayNext setting — when disabled we + // stay on the ended frame rather than silently advancing. Only trigger once per + // session (showNextEpisodePrompt guard) to avoid re-triggering on tick loops. + if (exoPlayer.playbackState == Player.STATE_ENDED && + mediaType == MediaType.TV && + !showNextEpisodePrompt && + !showSourceMenu && + !showSubtitleMenu && + uiState.error == null + ) { + if (seasonNumber != null && episodeNumber != null && uiState.autoPlayNext) { val selected = uiState.selectedStream - onPlayNext( - seasonNumber, - episodeNumber + 1, - selected?.addonId?.takeIf { it.isNotBlank() }, - selected?.source?.takeIf { it.isNotBlank() }, - selected?.behaviorHints?.bingeGroup?.takeIf { it.isNotBlank() } - ) + pendingNextSeason = seasonNumber + pendingNextEpisode = episodeNumber + 1 + pendingNextAddonId = selected?.addonId?.takeIf { it.isNotBlank() } + pendingNextSourceName = selected?.source?.takeIf { it.isNotBlank() } + pendingNextBingeGroup = selected?.behaviorHints?.bingeGroup?.takeIf { it.isNotBlank() } + showNextEpisodePrompt = true } } @@ -1305,8 +1325,8 @@ fun PlayerScreen( } // Request focus on the container when not showing controls - LaunchedEffect(showControls, showSubtitleMenu, showSourceMenu, uiState.error) { - if (!showControls && !showSubtitleMenu && !showSourceMenu && uiState.error == null) { + LaunchedEffect(showControls, showSubtitleMenu, showSourceMenu, showNextEpisodePrompt, uiState.error) { + if (!showControls && !showSubtitleMenu && !showSourceMenu && !showNextEpisodePrompt && uiState.error == null) { delay(100) try { containerFocusRequester.requestFocus() @@ -2151,6 +2171,39 @@ fun PlayerScreen( } ) + // Post-episode "Up Next" prompt (issue #86). Shown when a TV episode ends and + // autoPlayNext is enabled. 10-second countdown auto-advances, or the user can + // hit Enter to continue immediately or Back/Escape/Close to cancel and stay on + // the ended frame. Placed after StreamSelector so it renders above the player + // but below any error/source overlays that might appear simultaneously. + NextEpisodeOverlay( + isVisible = showNextEpisodePrompt, + showTitle = uiState.title, + // We only know the current episode's title at this point; fetching the next + // episode's metadata would require an extra TMDB round-trip during playback. + // Fall back to a generic "Episode N" label — the show title, S/E number, and + // backdrop image still give users enough context to decide Continue/Cancel. + episodeTitle = "Episode $pendingNextEpisode", + seasonNumber = pendingNextSeason, + episodeNumber = pendingNextEpisode, + episodeImage = uiState.backdropUrl, + countdownSeconds = 10, + onPlayNext = { + showNextEpisodePrompt = false + onPlayNext( + pendingNextSeason, + pendingNextEpisode, + pendingNextAddonId, + pendingNextSourceName, + pendingNextBingeGroup + ) + }, + onCancel = { + showNextEpisodePrompt = false + // Stay on the ended frame — user can hit Back to leave the player. + } + ) + // Volume indicator AnimatedVisibility( visible = showVolumeIndicator, 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 c27188fe9..115bbe385 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 @@ -60,6 +60,10 @@ data class PlayerUiState( val subtitleColor: String = "White", val error: String? = null, val isSetupError: Boolean = false, // true when error is due to missing addons (shows friendly guide instead of red error) + // Auto-play next episode at end of current one. Mirrors the profile-scoped + // "auto_play_next" DataStore setting so the player can respect the toggle + // and so the post-episode overlay can show a Continue/Cancel prompt. + val autoPlayNext: Boolean = true, // Skip intro/recap val activeSkipInterval: SkipInterval? = null, val skipIntervalDismissed: Boolean = false @@ -177,13 +181,15 @@ class PlayerViewModel @Inject constructor( val frameRateMatchingMode = resolveFrameRateMatchingMode() val subSize = context.settingsDataStore.data.first()[profileManager.profileStringKey("subtitle_size")] ?: "Medium" val subColor = context.settingsDataStore.data.first()[profileManager.profileStringKey("subtitle_color")] ?: "White" + val autoPlayNext = context.settingsDataStore.data.first()[profileManager.profileBooleanKey("auto_play_next")] ?: true _uiState.value = PlayerUiState( isLoading = true, isLoadingStreams = true, preferredAudioLanguage = preferredAudioLanguage, frameRateMatchingMode = frameRateMatchingMode, subtitleSize = subSize, - subtitleColor = subColor + subtitleColor = subColor, + autoPlayNext = autoPlayNext ) // If stream URL provided, use it directly (except magnet links, which require resolution).