From ed6270552b87e7558904c0b00544a97dc961676a Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 1 May 2026 01:01:46 -0400 Subject: [PATCH] Recover stuck live playback when play is pressed Adds recoverFromStuck() and rewires the play-button click handler to call it before falling through to the normal play path. When the player has dropped into STATE_IDLE after a fatal error or STATE_ENDED on a slipped live window, plain play() is a no-op until we re-prepare. recoverFromStuck() reloads the cached media source and, on live, snaps to the live edge -- so pressing play recovers in place instead of forcing the user to back out of the video and reopen it. Non-live STATE_ENDED is left alone so the existing seek-to-0 replay path that VideoDetailView's onSourceEnded handler primes via setIsReplay(true) keeps working. The play handler also tightens the existing replay rewind: the previous 'contentPosition >= duration' check would seekTo(0) on live streams because Player.duration is C.TIME_UNSET (a large negative value); the new 'dur > 0 && pos >= dur' guard skips that for live and only rewinds finished VODs. --- .../views/video/FutoVideoPlayer.kt | 34 ++++++++-------- .../views/video/FutoVideoPlayerBase.kt | 40 +++++++++++++++++++ 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt index b62c8f50..92f83598 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayer.kt @@ -225,24 +225,26 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _buttonNext.setOnClickListener { onNext.emit() }; _buttonPrevious_fullscreen.setOnClickListener { onPrevious.emit() }; _buttonNext_fullscreen.setOnClickListener { onNext.emit() }; - _control_play.setOnClickListener { - exoPlayer?.player?.let { - if (it.contentPosition >= it.duration) { - it.seekTo(0) + val playClickHandler = View.OnClickListener { + // Order matters: + // 1. If the player is stuck (STATE_IDLE after error, STATE_ENDED on a slipped live + // window) plain play() is a no-op until we re-prepare. Recover first. + // 2. Otherwise, if a VOD has played to its end, rewind to start (replay). + // 3. Then start playback. + val recovered = recoverFromStuck() + if (!recovered) { + exoPlayer?.player?.let { + val dur = it.duration + if (dur > 0 && it.contentPosition >= dur) { + it.seekTo(0) + } + it.play() } - exoPlayer?.player?.play(); } - updatePlayPause(); - }; - _control_play_fullscreen.setOnClickListener { - exoPlayer?.player?.let { - if (it.contentPosition >= it.duration) { - it.seekTo(0) - } - exoPlayer?.player?.play(); - } - updatePlayPause(); - }; + updatePlayPause() + } + _control_play.setOnClickListener(playClickHandler) + _control_play_fullscreen.setOnClickListener(playClickHandler) _control_pause.setOnClickListener { exoPlayer?.player?.pause(); updatePlayPause(); diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt index ef3c45cf..af5ce802 100644 --- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt +++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt @@ -414,6 +414,46 @@ abstract class FutoVideoPlayerBase : RelativeLayout { player.seekToDefaultPosition() } + /** + * Recovers playback when the player is stuck in a non-recoverable state. + * Returns true if recovery was attempted (caller should not also call play()). + * + * STATE_IDLE happens after an unrecoverable error; STATE_ENDED happens on live when the + * window slips past the player. For both, plain play() is a no-op until we re-prepare; for + * live we additionally seek to the live edge so the user lands where YouTube's UI would. + * + * Non-live STATE_ENDED is *not* stuck -- it's the user pressing replay on a finished VOD -- + * so we deliberately fall through to the caller, which seeks to 0 and plays. Without this + * guard the play button would re-prepare and seek to the saved end position, immediately + * re-entering STATE_ENDED, and the replay icon set by [setIsReplay] would never replay. + * We key off the sticky [_isLiveSession] rather than [Player.isCurrentMediaItemLive] because + * the latter can flip false during a live-window slip even though the user *is* watching live. + */ + fun recoverFromStuck(): Boolean { + val player = exoPlayer?.player ?: return false + val state = player.playbackState + if (state != Player.STATE_IDLE && state != Player.STATE_ENDED) return false + if (state == Player.STATE_ENDED && !_isLiveSession) return false + Logger.i(TAG, "recoverFromStuck state=$state isLive=${player.isCurrentMediaItemLive}") + // Reload the current source if available; preserves position via reloadMediaSource(resume=true) + // but for live we want to land at the edge. + if (_mediaSource != null) { + val wasLive = player.isCurrentMediaItemLive + reloadMediaSource(play = true, resume = !wasLive) + if (wasLive) { + exoPlayer?.player?.seekToDefaultPosition() + } + } else { + // No media source cached yet; just re-prepare what's loaded. + player.prepare() + player.playWhenReady = true + if (player.isCurrentMediaItemLive) { + player.seekToDefaultPosition() + } + } + return true + } + /** * Best-effort auto-reload for a live stream after a transient HLS/IO error. * Debounces consecutive reloads (min [LIVE_RELOAD_MIN_INTERVAL_MS] apart) and caps