Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 65 additions & 12 deletions app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String?>(null) }
var pendingNextSourceName by remember { mutableStateOf<String?>(null) }
var pendingNextBingeGroup by remember { mutableStateOf<String?>(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
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
Loading