From 9639c2a167532d734d8d0e6638090dd480e7c7a2 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 1 May 2026 00:59:17 -0400 Subject: [PATCH] Auto-reload live streams on transient errors Adds a silent retry path for live HLS playback when the player raises a transient I/O or parsing error: tryLiveAutoReload() reloads the source and snaps to the live edge, with a 3s debounce and a 5-attempt cap so a permanently broken source does not spin in a loop. Triggered from: - onPlayerError, for the error codes a transient outage typically produces (IO_NETWORK_*, IO_BAD_HTTP_STATUS, IO_INVALID_HTTP_*, IO_UNSPECIFIED, PARSING_CONTAINER_MALFORMED, PARSING_MANIFEST_MALFORMED) - onPlaybackStateChanged, when STATE_ENDED hits on a live session (covers cases where the player cannot raise an error before the timeline empties) On STATE_READY the attempts counter resets so future hiccups get a fresh budget. The counters also reset on swapSourceInternal/clear so a previous session's attempts cannot bleed into the next. The dispatch is gated on the sticky _isLiveSession flag rather than the dynamic isCurrentMediaItemLive: the latter flips false during the transient empty timeline that a reload produces, which would otherwise break the retry chain after the first attempt. --- .../views/video/FutoVideoPlayerBase.kt | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) 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 ce2578ba..ef3c45cf 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 @@ -209,6 +209,11 @@ abstract class FutoVideoPlayerBase : RelativeLayout { */ private var _isLiveSession: Boolean = false + /** Timestamp (ms) of last live auto-reload, used to back off duplicate reloads. */ + private var _lastLiveReloadAt_ms: Long = 0 + /** Consecutive live auto-reload attempts; resets on successful playback. */ + private var _liveReloadAttempts: Int = 0 + private val _playerEventListener = object: Player.Listener { override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) { super.onPlaybackSuppressionReasonChanged(playbackSuppressionReason) @@ -409,6 +414,40 @@ abstract class FutoVideoPlayerBase : RelativeLayout { player.seekToDefaultPosition() } + /** + * 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 + * attempts at [LIVE_RELOAD_MAX_ATTEMPTS] before giving up so we don't spin on a + * permanently-broken source. + * + * Returns true if a reload was actually issued. + */ + private fun tryLiveAutoReload(reason: String): Boolean { + if (!_isLiveSession) return false + val now = System.currentTimeMillis() + val sinceLast = now - _lastLiveReloadAt_ms + if (sinceLast < LIVE_RELOAD_MIN_INTERVAL_MS) { + Logger.i(TAG, "tryLiveAutoReload skipped ($reason): last=${sinceLast}ms ago") + return false + } + if (_liveReloadAttempts >= LIVE_RELOAD_MAX_ATTEMPTS) { + Logger.w(TAG, "tryLiveAutoReload giving up ($reason): attempts=$_liveReloadAttempts") + return false + } + _lastLiveReloadAt_ms = now + _liveReloadAttempts += 1 + Logger.i(TAG, "tryLiveAutoReload ($reason): attempt=$_liveReloadAttempts") + if (_mediaSource != null) { + reloadMediaSource(play = true, resume = false) + exoPlayer?.player?.seekToDefaultPosition() + } else { + exoPlayer?.player?.prepare() + exoPlayer?.player?.playWhenReady = true + exoPlayer?.player?.seekToDefaultPosition() + } + return true + } + fun changePlayer(newPlayer: PlayerManager?) { exoPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = null}); newPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = _playerEventListener}); @@ -574,6 +613,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout { // swaps within an existing session must NOT reset live-state, so the audio/subtitle // overloads deliberately do not duplicate this block. _isLiveSession = false + _liveReloadAttempts = 0 + _lastLiveReloadAt_ms = 0 setLoading(false) val swapId = _swapIdVideo.incrementAndGet() _lastGeneratedDash = null; @@ -1088,6 +1129,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout { _lastSubtitleMediaSource = null; _mediaSource = null; _isLiveSession = false + _liveReloadAttempts = 0 + _lastLiveReloadAt_ms = 0 } fun stop(){ @@ -1131,6 +1174,15 @@ abstract class FutoVideoPlayerBase : RelativeLayout { return; } + // For live streams, transient HLS/IO errors usually mean a segment expired or + // the manifest tracker fell behind. Re-prepare and snap to the live edge so the + // user does not have to back out of the video to recover. + if (_isLiveSession && isLiveAutoRecoverableError(error)) { + if (tryLiveAutoReload("onPlayerError code=${error.errorCode}")) { + return; + } + } + when (error.errorCode) { PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> { Logger.w(TAG, "ERROR_CODE_IO_BAD_HTTP_STATUS ${error.cause?.javaClass?.simpleName}"); @@ -1149,7 +1201,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout { //PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, //PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, //PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, //PlaybackException.ERROR_CODE_IO_NO_PERMISSION, //PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, @@ -1164,6 +1215,23 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } } + /** + * Whether a player error is the kind we should silently auto-reload on for live streams. + * Excludes hard failures (DRM, decoder, malformed) where reloading won't help. + */ + private fun isLiveAutoRecoverableError(error: PlaybackException): Boolean { + return when (error.errorCode) { + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, + PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> true + else -> false + } + } + protected open fun onVideoSizeChanged(videoSize: VideoSize) { } @@ -1180,6 +1248,17 @@ abstract class FutoVideoPlayerBase : RelativeLayout { Logger.i(TAG, "_shouldPlaybackRestartOnConnectivity=false"); _shouldPlaybackRestartOnConnectivity = false; } + if (playbackState == ExoPlayer.STATE_READY) { + // Successful prepare cycle; reset the attempts counter so future hiccups get a + // fresh budget. The debounce timestamp does not need clearing -- a new error after + // any non-trivial play interval will be well past LIVE_RELOAD_MIN_INTERVAL_MS. + _liveReloadAttempts = 0 + } + if (playbackState == ExoPlayer.STATE_ENDED && _isLiveSession) { + // A live stream that 'ends' from the player's perspective is almost always a window + // slip or stalled tracker. Try to silently rejoin at the live edge. + tryLiveAutoReload("STATE_ENDED on live") + } } protected open fun setLoading(isLoading: Boolean) { } @@ -1208,6 +1287,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout { * generous enough to cover typical HLS/DASH live latencies (15-30s). */ const val LIVE_EDGE_FALLBACK_THRESHOLD_MS = 45_000L + /** Min interval between live auto-reload attempts (debounce). */ + const val LIVE_RELOAD_MIN_INTERVAL_MS = 3_000L + /** Max consecutive auto-reloads before giving up to avoid loops on broken sources. */ + const val LIVE_RELOAD_MAX_ATTEMPTS = 5 val SUPPORTED_SUBTITLES = hashSetOf("text/vtt", "application/x-subrip"); }