From 536a74ce474ef02c91a5417652293120ca53094a Mon Sep 17 00:00:00 2001 From: rxwatcher Date: Mon, 27 Jul 2026 08:28:40 +0200 Subject: [PATCH] feat(tv): add a manual Up Next control to the player transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports silo-apple#86. Android TV already surfaced Up Next automatically at the credits, but there was no way to reach it early — a viewer who is done with an episode had to sit through the outro or leave the player and navigate back in. The control appears in the transport's secondary group only when a next episode is actually resolved, and the predicate lives on the view model so the manual button and the automatic trigger cannot disagree about whether there is anything to advance to. A button that appears when the automatic path would find nothing is a button that does nothing. It deliberately does NOT start the auto-advance countdown. Someone who opened this themselves is choosing; a timer that pulls them into the next episode mid-decision is the opposite of what the press asked for. The automatic path keeps its countdown and its pass-out gating untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK --- .../tv/ui/screens/player/TvPlayerScreen.kt | 11 ++++++ .../player/TvPlayerTransportCluster.kt | 16 +++++++++ .../tv/ui/screens/player/TvPlayerViewModel.kt | 34 +++++++++++++++++++ 3 files changed, 61 insertions(+) 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