diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt index ad7a10d90..2106b367f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerScreen.kt @@ -1838,6 +1838,14 @@ fun TvPlayerScreen( showQuickSubtitlePicker = true viewModel.setControlsVisible(true) }, + // Only offered when there is something to advance to; + // the view model owns that predicate so the manual + // control and the automatic trigger cannot disagree. + onUpNext = if (viewModel.canShowNextUpNow()) { + { viewModel.onUserRequestedNextUp() } + } else { + null + }, onClose = { when { roomController != null && roomSnapshot?.isHost == true -> @@ -2227,6 +2235,8 @@ private fun TvPlayerIdleOverlay( focusRequest: TvIdleOverlayFocusRequest, onOpenHUD: () -> Unit, onOpenQuickSubtitles: () -> Unit, + /** Non-null only while a next episode is resolved and not already shown. */ + onUpNext: (() -> Unit)? = null, onClose: () -> Unit, // Watch Together transport authority. Solo playback leaves both true. // A guest who can't seek gets a no-op scrubber/skip; a guest who can't @@ -2345,6 +2355,7 @@ private fun TvPlayerIdleOverlay( onPlayPause = onPlayPause, onSkipForward = onSkipForward, onOpenQuickSubtitles = onOpenQuickSubtitles, + onUpNext = onUpNext, onOpenHUD = onOpenHUD, onClose = onClose, playPauseFocus = playPauseFocus, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerTransportCluster.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerTransportCluster.kt index c2962788a..37620e125 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerTransportCluster.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerTransportCluster.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.filled.Forward30 import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Replay10 +import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.Tune import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -62,6 +63,12 @@ fun TvPlayerTransportCluster( onPlayPause: () -> Unit, onSkipForward: () -> Unit, onOpenQuickSubtitles: () -> Unit, + /** + * Non-null only when there is a next episode to show. Mirrors + * silo-apple#86: the automatic trigger fires at the credits, and this lets + * a viewer who is already done reach it early. + */ + onUpNext: (() -> Unit)? = null, onOpenHUD: () -> Unit, onClose: () -> Unit, playPauseFocus: FocusRequester, @@ -101,6 +108,15 @@ fun TvPlayerTransportCluster( // Secondary group — pushed right. Row(verticalAlignment = Alignment.CenterVertically) { + onUpNext?.let { showUpNext -> + TransportIconButton( + icon = Icons.Filled.SkipNext, + description = "Up Next", + onClick = showUpNext, + onMoveUp = onMoveUpToScrubber, + ) + DockGap() + } TransportIconButton( icon = Icons.Filled.ClosedCaption, description = "Subtitles", diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt index 7d5cf2b7d..83458d703 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/player/TvPlayerViewModel.kt @@ -2292,6 +2292,40 @@ class TvPlayerViewModel( commitApproachingEnd(next, videoEnded) } + /** + * Whether an Up Next control has anything to show right now. + * + * Shared with the automatic path deliberately: a manual button that can + * appear when the automatic trigger would find nothing is a button that + * does nothing when pressed. + */ + fun canShowNextUpNow(): Boolean { + val state = _uiState.value + return state.nextEpisode != null && !state.showNextUp + } + + /** + * Surface Up Next on demand, ahead of the credits trigger. + * + * Routed through the same commit the automatic timing uses so the overlay, + * the countdown gating and the auto-advance accounting behave identically — + * the only difference is what asked for it. Mirrors silo-apple#86, which + * added the equivalent control to the tvOS transport. + * + * The countdown is deliberately NOT started here: someone who opened this + * themselves is choosing, and a timer that yanks them into the next episode + * mid-decision is the opposite of what the press asked for. + */ + fun onUserRequestedNextUp() { + val next = _uiState.value.nextEpisode ?: return + if (_uiState.value.showNextUp) return + nextUpCountdownJob?.cancel() + nextUpCountdownJob = null + _uiState.update { + it.copy(showNextUp = true, nextUpCountdownSeconds = null) + } + } + private fun commitApproachingEnd(next: NextEpisodeState, videoEnded: Boolean) { autoAdvanceHandled = true pendingApproachingEndVideoEnded = null