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.
This commit is contained in:
Simon Gardling
2026-05-01 01:01:46 -04:00
parent 9639c2a167
commit ed6270552b
2 changed files with 58 additions and 16 deletions
@@ -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();
@@ -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