From 7a6b185e9d1d2670206e7a7fdb10992398f2e6fb Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 1 May 2026 00:14:28 -0400 Subject: [PATCH 1/7] Format negative durations correctly formatDuration() previously emitted strings like '00:-49' for negative values because the modulo operations propagated the sign. This shows up on live streams that briefly report a negative position during reload, and as the basis for a planned 'behind live edge' indicator. Recurse on the absolute value when negative so we get a clean -MM:SS (or -HH:MM:SS) format. --- .../java/com/futo/platformplayer/Extensions_Formatting.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt index 42210c60..d7bd898f 100644 --- a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt +++ b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt @@ -251,6 +251,11 @@ fun String.fixHtmlWhitespace(): Spanned { } fun Long.formatDuration(): String { + // Negative durations show up for live streams seeked behind the seek-window start, or + // briefly while the player is reseating. Recurse on the absolute value so we get a clean + // `-MM:SS` instead of garbage like `00:-49`. + if (this < 0) return "-" + (-this).formatDuration() + val hours = this / 3600000 val minutes = (this % 3600000) / 60000 val seconds = (this % 60000) / 1000 From 4bb09ec92e5c7e014a6ab416a16846b74cbbb5e6 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 1 May 2026 00:17:06 -0400 Subject: [PATCH 2/7] Fix BehindLiveWindowException recovery on live HLS The 'error is BehindLiveWindowException' check in onPlayerError was always false (Kotlin compiler had been warning about it). The actual exception is wrapped as the *cause* of an ExoPlaybackException, with errorCode 1002 (ERROR_CODE_BEHIND_LIVE_WINDOW). This made the existing recovery branch dead code, so when the live window slipped past the player it would silently drop into STATE_IDLE and the user had to back out of the video and reopen it. Test the cause and the error code so the branch actually fires, and snap to the live edge with seekToDefaultPosition() after both the BehindLiveWindow and PlaylistStuckException reloads so the user lands where the player would naturally play. --- .../platformplayer/views/video/FutoVideoPlayerBase.kt | 9 ++++++++- 1 file changed, 8 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 0404e463..7acf0bf4 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 @@ -1013,14 +1013,21 @@ abstract class FutoVideoPlayerBase : RelativeLayout { protected open fun onPlayerError(error: PlaybackException) { Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss, cause=${error.cause}"); - if(error is BehindLiveWindowException) { + // BehindLiveWindowException is wrapped as the *cause* of an ExoPlaybackException, so + // checking `error is BehindLiveWindowException` is always false (compiler warns). Use + // both the cause and the dedicated error code 1002 (ERROR_CODE_BEHIND_LIVE_WINDOW) + // so we recover whether the exception bubbled up wrapped or as an error code only. + if (error.cause is BehindLiveWindowException + || error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) { Logger.e(TAG, "BehindLiveWindowException, " + error.message); reloadMediaSource(true, true); + exoPlayer?.player?.seekToDefaultPosition(); return; } if(error != null && error.cause is HlsPlaylistTracker.PlaylistStuckException) { Logger.e(TAG, "PlaylistStuckException"); reloadMediaSource(true, true); + exoPlayer?.player?.seekToDefaultPosition(); UIDialogs.toast("Live playback error, reloading.."); return; } From b5caea655673a3f0f6e20ff334cf4d4242a6e7f1 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 1 May 2026 00:22:18 -0400 Subject: [PATCH 3/7] Expose live-stream state and edge detection Foundational hooks for follow-up live-recovery and UI work; no behaviour change on its own. - isLive: whether the current media item is live - liveOffsetMs: offset behind wall-clock live edge - targetLiveOffsetMs: manifest's intended offset (null if not declared) - isAtLiveEdge: target-aware boundary, with a 45s fallback for sources (e.g. YouTube HLS) that do not declare targetOffsetMs - seekToLiveEdge(): wraps Player.seekToDefaultPosition() for live items - onLiveChanged event, fired from onTimelineChanged/onMediaItemTransition - _isLiveSession sticky flag: stays true through the transient empty timeline the player goes through during a reload, so consumers can distinguish 'a live source is loaded' from the dynamic isLive bit LIVE_EDGE_TOLERANCE_MS = 5s and LIVE_EDGE_FALLBACK_THRESHOLD_MS = 45s are tuned to match what YouTube's HLS player reports (currentLiveOffset sits at ~25-30s natively even at the edge, so a tighter threshold would report 'behind' forever). --- .../views/video/FutoVideoPlayerBase.kt | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) 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 7acf0bf4..ce2578ba 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 @@ -17,6 +17,7 @@ import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player +import androidx.media3.common.Timeline import androidx.media3.common.VideoSize import androidx.media3.common.text.CueGroup import androidx.media3.common.util.UnstableApi @@ -129,6 +130,52 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val position: Long get() = exoPlayer?.player?.currentPosition ?: 0; val duration: Long get() = exoPlayer?.player?.duration ?: 0; + /** True when the current media item is a live stream. */ + val isLive: Boolean get() = exoPlayer?.player?.isCurrentMediaItemLive ?: false + + /** + * Live offset reported by the player in ms (ms behind live edge, 0 == at edge). + * Returns null when not live or when offset is unavailable. + */ + val liveOffsetMs: Long? get() { + val player = exoPlayer?.player ?: return null + if (!player.isCurrentMediaItemLive) return null + val offset = player.currentLiveOffset + return if (offset == C.TIME_UNSET) null else offset + } + + /** + * Target live offset (ms) the player wants to maintain behind the wall-clock edge. + * Comes from the manifest's [MediaItem.LiveConfiguration]; YouTube HLS typically reports + * 15-30s. Returns null when not live or when no target is configured. + */ + val targetLiveOffsetMs: Long? get() { + val player = exoPlayer?.player ?: return null + if (!player.isCurrentMediaItemLive) return null + val target = player.currentMediaItem?.liveConfiguration?.targetOffsetMs + ?: return null + return if (target == C.TIME_UNSET) null else target + } + + /** + * Whether the player is at the live edge from a user perspective: current offset is + * within [LIVE_EDGE_TOLERANCE_MS] of the manifest's target offset (or, if no target is + * known, within [LIVE_EDGE_FALLBACK_THRESHOLD_MS] of wall-clock). + * + * The naive "offset <= 5s" check fails for YouTube HLS, which sets target offsets of + * ~18-30s -- after [Player.seekToDefaultPosition] the player snaps to the target, not + * to wall clock, so a tighter threshold reports "behind" forever. + */ + val isAtLiveEdge: Boolean get() { + val offset = liveOffsetMs ?: return false + val target = targetLiveOffsetMs + return if (target != null) { + offset - target <= LIVE_EDGE_TOLERANCE_MS + } else { + offset <= LIVE_EDGE_FALLBACK_THRESHOLD_MS + } + } + var isAudioMode: Boolean = false private set; @@ -136,6 +183,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val onStateChange = Event1(); val onPositionDiscontinuity = Event1(); val onDatasourceError = Event1(); + /** Emits when live state (live vs not) of the current media item changes. */ + val onLiveChanged = Event1(); val onReloadRequired = Event0(); @@ -150,6 +199,16 @@ abstract class FutoVideoPlayerBase : RelativeLayout { private var _toResume = false; + private var _wasLive: Boolean = false + /** + * Sticky 'live session' flag. Goes true when the player observes a live media item, and + * stays true through transient timeline-empty events (e.g. while a reload is in flight). + * Only cleared when the source actually changes (swapSourceInternal / clear). Without this, + * `isCurrentMediaItemLive` flips to false during a reload and the second error in a chain + * skips the live-recovery branch -- breaking the auto-reload retry sequence. + */ + private var _isLiveSession: Boolean = false + private val _playerEventListener = object: Player.Listener { override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) { super.onPlaybackSuppressionReasonChanged(playbackSuppressionReason) @@ -217,6 +276,30 @@ abstract class FutoVideoPlayerBase : RelativeLayout { Logger.i(TAG, "CUE GROUP: ${cueGroup.cues.firstOrNull()?.text}"); } + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + super.onTimelineChanged(timeline, reason) + checkLiveStateChanged() + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + super.onMediaItemTransition(mediaItem, reason) + checkLiveStateChanged() + } + + private fun checkLiveStateChanged() { + val nowLive = exoPlayer?.player?.isCurrentMediaItemLive ?: false + if (nowLive) { + // Sticky: any observation of a live item locks the session in until the source + // is replaced. Survives transient timeline-empty events during reloads. + _isLiveSession = true + } + if (nowLive != _wasLive) { + _wasLive = nowLive + Logger.i(TAG, "isCurrentMediaItemLive changed -> $nowLive (session=$_isLiveSession)") + onLiveChanged.emit(nowLive) + } + } + override fun onPlayerError(error: PlaybackException) { super.onPlayerError(error); this@FutoVideoPlayerBase.onPlayerError(error); @@ -315,6 +398,17 @@ abstract class FutoVideoPlayerBase : RelativeLayout { exoPlayer?.player?.seekTo(Math.min(to, exoPlayer?.player?.duration ?: to)); } + /** + * Seeks to the live edge of the current dynamic window. No-op if not live. + * Uses [Player.seekToDefaultPosition] which targets the live edge in HLS/DASH dynamic windows. + */ + fun seekToLiveEdge() { + val player = exoPlayer?.player ?: return + if (!player.isCurrentMediaItemLive) return + Logger.i(TAG, "seekToLiveEdge (offset=${player.currentLiveOffset}ms)") + player.seekToDefaultPosition() + } + fun changePlayer(newPlayer: PlayerManager?) { exoPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = null}); newPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = _playerEventListener}); @@ -476,6 +570,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout { private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean { + // The video source is what defines a playback session in this player. Audio/subtitle + // swaps within an existing session must NOT reset live-state, so the audio/subtitle + // overloads deliberately do not duplicate this block. + _isLiveSession = false setLoading(false) val swapId = _swapIdVideo.incrementAndGet() _lastGeneratedDash = null; @@ -989,6 +1087,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout { _lastAudioMediaSource = null; _lastSubtitleMediaSource = null; _mediaSource = null; + _isLiveSession = false } fun stop(){ @@ -1099,6 +1198,17 @@ abstract class FutoVideoPlayerBase : RelativeLayout { val PREFERED_AUDIO_CONTAINERS: Array get() { return if(Settings.instance.playback.preferWebmAudio) PREFERED_AUDIO_CONTAINERS_WEBMPref else PREFERED_AUDIO_CONTAINERS_MP4Pref } + /** + * Tolerance (ms) for being "at the live edge" relative to the manifest's target offset. + * Slack accounts for normal network jitter and the player drifting around the target. + */ + const val LIVE_EDGE_TOLERANCE_MS = 5_000L + /** + * Fallback threshold (ms) used when the manifest does not declare a target live offset: + * generous enough to cover typical HLS/DASH live latencies (15-30s). + */ + const val LIVE_EDGE_FALLBACK_THRESHOLD_MS = 45_000L + val SUPPORTED_SUBTITLES = hashSetOf("text/vtt", "application/x-subrip"); } } \ No newline at end of file From 9639c2a167532d734d8d0e6638090dd480e7c7a2 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 1 May 2026 00:59:17 -0400 Subject: [PATCH 4/7] 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"); } From ed6270552b87e7558904c0b00544a97dc961676a Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 1 May 2026 01:01:46 -0400 Subject: [PATCH 5/7] 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 From 985bd433a2ead54719c0a70c759e077dedd9f993 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 1 May 2026 00:31:37 -0400 Subject: [PATCH 6/7] Add LIVE pill with at-edge indicator and tap-to-jump LIVE pill rendered next to the time display (regular + fullscreen layouts): - red filled with a white dot when at the live edge - gray bordered with a muted dot when behind - tap to jump to the live edge While live, the duration text + divider, loop button, and chapter view are hidden -- they're meaningless on a live stream and were just visual noise. Position text stays visible since for HLS DVR streams it shows offset within the available seek window. Pill state updates in the existing PlayerControlView progress tick; applyLiveUI() switches the surrounding UI on the onLiveChanged event. At construction the current isLive value is applied once so that attaching to an already-live media item starts in the right state. The chapter view's left-constraint now chains through live_pill_container, which collapses to width 0 when the pill is GONE on VOD -- so the chapter view sits in the same place it always did when live is off. --- .../views/video/FutoVideoPlayer.kt | 91 +++++++++++++++++++ .../res/drawable/background_live_pill.xml | 6 ++ .../drawable/background_live_pill_behind.xml | 7 ++ app/src/main/res/drawable/dot_live_behind.xml | 7 ++ app/src/main/res/drawable/dot_live_edge.xml | 7 ++ app/src/main/res/layout/video_player_ui.xml | 43 ++++++++- .../res/layout/video_player_ui_fullscreen.xml | 41 ++++++++- app/src/main/res/values/strings.xml | 1 + 8 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/drawable/background_live_pill.xml create mode 100644 app/src/main/res/drawable/background_live_pill_behind.xml create mode 100644 app/src/main/res/drawable/dot_live_behind.xml create mode 100644 app/src/main/res/drawable/dot_live_edge.xml 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 92f83598..c7bf3e5c 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 @@ -18,6 +18,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout +import android.widget.LinearLayout import android.widget.ImageButton import android.widget.TextView import androidx.annotation.OptIn @@ -119,6 +120,15 @@ class FutoVideoPlayer : FutoVideoPlayerBase { private val _control_duration_fullscreen: TextView; private val _control_pause_fullscreen: ImageButton; + // LIVE pill: shown only when current media item is live; dot color reflects live-edge proximity. + private val _live_pill: LinearLayout + private val _live_pill_dot: View + private val _live_pill_fullscreen: LinearLayout + private val _live_pill_dot_fullscreen: View + private val _text_divider: TextView + private val _text_divider_fullscreen: TextView + private var _wasAtLiveEdge: Boolean = true + private val _title_fullscreen: TextView; private val _author_fullscreen: TextView; private var _shouldRestartHideJobOnPlaybackStateChange: Boolean = false; @@ -189,6 +199,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _buttonPrevious = videoControls.findViewById(R.id.button_previous); _control_time = videoControls.findViewById(R.id.text_position); _control_duration = videoControls.findViewById(R.id.text_duration); + _live_pill = videoControls.findViewById(R.id.live_pill_container) + _live_pill_dot = videoControls.findViewById(R.id.live_pill_dot) + _text_divider = videoControls.findViewById(R.id.text_divider) _videoControls_fullscreen = findViewById(R.id.video_player_controller_fullscreen); _control_autoplay_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_autoplay); @@ -206,6 +219,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _control_time_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_position); _control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration); _control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause); + _live_pill_fullscreen = _videoControls_fullscreen.findViewById(R.id.live_pill_container) + _live_pill_dot_fullscreen = _videoControls_fullscreen.findViewById(R.id.live_pill_dot) + _text_divider_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_divider) _loaderGame = findViewById(R.id.loader_overlay) _loaderGame.visibility = View.GONE @@ -475,6 +491,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase { _time_bar_fullscreen.setBufferedPosition(bufferedPosition); _time_bar.setBufferedPosition(bufferedPosition); + // While live, refresh the LIVE pill's edge state so the dot reflects whether the user + // is at the live edge or seeked behind. Cheap and only updates when state actually changes. + if (isLive) { + updateLiveEdgeState() + } + onTimeBarChanged.emit(position, bufferedPosition); if(!_currentChapterLoopActive) @@ -501,6 +523,23 @@ class FutoVideoPlayer : FutoVideoPlayerBase { } } + // Toggle LIVE pill / time UI when the underlying media item changes liveness. + // The base class emits this on Timeline / MediaItem transitions. + onLiveChanged.subscribe { live -> + CoroutineScope(Dispatchers.Main).launch(Dispatchers.Main) { + applyLiveUI(live) + } + } + + val jumpToLiveListener = View.OnClickListener { + seekToLiveEdge() + } + _live_pill.setOnClickListener(jumpToLiveListener) + _live_pill_fullscreen.setOnClickListener(jumpToLiveListener) + + // Apply once at construction in case we attach to an already-live media item. + applyLiveUI(isLive) + updateLoopVideoUI(); if(!isInEditMode) { @@ -897,6 +936,58 @@ class FutoVideoPlayer : FutoVideoPlayerBase { } } + /** + * Applies (or reverts) live-stream-specific control affordances: + * - shows/hides the LIVE pill + * - hides the duration text + divider when live (duration shown by the pill) + * - hides the loop button (looping a live stream is meaningless) + * - hides the chapter text (live streams from the source plugins do not provide chapters) + * + * Position text is kept visible because for HLS DVR streams it shows offset within the + * available seek window, which is useful information. + */ + private fun applyLiveUI(live: Boolean) { + val pillVis = if (live) View.VISIBLE else View.GONE + val timeVis = if (live) View.GONE else View.VISIBLE + _live_pill.visibility = pillVis + _live_pill_fullscreen.visibility = pillVis + _text_divider.visibility = timeVis + _text_divider_fullscreen.visibility = timeVis + _control_duration.visibility = timeVis + _control_duration_fullscreen.visibility = timeVis + + if (live) { + // Loop / chapter UI is meaningless on live; hide and reset. + _control_loop.visibility = View.GONE + _control_loop_fullscreen.visibility = View.GONE + _control_chapter.visibility = View.GONE + _control_chapter_fullscreen.visibility = View.GONE + updateLiveEdgeState() + } else { + _control_loop.visibility = View.VISIBLE + _control_loop_fullscreen.visibility = View.VISIBLE + _control_chapter.visibility = View.VISIBLE + _control_chapter_fullscreen.visibility = View.VISIBLE + } + } + + /** + * Updates the LIVE pill's dot + background to reflect whether playback is at the live edge. + * Idempotent: only mutates when state changes to avoid invalidations on every progress tick. + */ + private fun updateLiveEdgeState() { + val atEdge = isAtLiveEdge + if (atEdge == _wasAtLiveEdge) return + _wasAtLiveEdge = atEdge + Logger.i(TAG, "LIVE pill -> ${if (atEdge) "AT EDGE" else "BEHIND"} (offset=${liveOffsetMs}ms target=${targetLiveOffsetMs}ms)") + val bg = if (atEdge) R.drawable.background_live_pill else R.drawable.background_live_pill_behind + val dot = if (atEdge) R.drawable.dot_live_edge else R.drawable.dot_live_behind + _live_pill.setBackgroundResource(bg) + _live_pill_fullscreen.setBackgroundResource(bg) + _live_pill_dot.setBackgroundResource(dot) + _live_pill_dot_fullscreen.setBackgroundResource(dot) + } + fun setGestureSoundFactor(soundFactor: Float) { gestureControl.setSoundFactor(soundFactor); } diff --git a/app/src/main/res/drawable/background_live_pill.xml b/app/src/main/res/drawable/background_live_pill.xml new file mode 100644 index 00000000..e0cc9fb4 --- /dev/null +++ b/app/src/main/res/drawable/background_live_pill.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/background_live_pill_behind.xml b/app/src/main/res/drawable/background_live_pill_behind.xml new file mode 100644 index 00000000..79fb7d70 --- /dev/null +++ b/app/src/main/res/drawable/background_live_pill_behind.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/dot_live_behind.xml b/app/src/main/res/drawable/dot_live_behind.xml new file mode 100644 index 00000000..a99d059a --- /dev/null +++ b/app/src/main/res/drawable/dot_live_behind.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/dot_live_edge.xml b/app/src/main/res/drawable/dot_live_edge.xml new file mode 100644 index 00000000..01d8e7cb --- /dev/null +++ b/app/src/main/res/drawable/dot_live_edge.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/layout/video_player_ui.xml b/app/src/main/res/layout/video_player_ui.xml index 8e31747c..0015512b 100644 --- a/app/src/main/res/layout/video_player_ui.xml +++ b/app/src/main/res/layout/video_player_ui.xml @@ -195,6 +195,47 @@ app:layout_constraintTop_toTopOf="@id/text_position" app:layout_constraintBottom_toBottomOf="@id/text_position"/> + + + + + + + + + + + + + + + + Stop Scan QR code Help + Jump to live edge Change Polycentric profile picture Recommendations From 845c6b00317ce245f876fd85fbdb5b7398b03368 Mon Sep 17 00:00:00 2001 From: Simon Gardling Date: Fri, 1 May 2026 00:37:58 -0400 Subject: [PATCH 7/7] Show offset behind live edge as -MM:SS in time display When a live stream has been seeked behind, replace the running position with a -MM:SS 'behind live' indicator (the videojs/HLS convention; matches the offset readout commonly seen in third-party YouTube live tooling). At the live edge we keep showing the running position. The offset uses [behindLiveMs], which subtracts the manifest's natural live offset (or LIVE_EDGE_FALLBACK_THRESHOLD_MS when the manifest doesn't declare one) so the value reflects the user-perceptible delay rather than the inherent ~25-30s HLS latency. The 'show as behind' boundary always agrees with the 'pill turns gray' boundary (both keyed on the same baseline), so the readout and the LIVE pill cannot disagree. --- .../platformplayer/views/video/FutoVideoPlayer.kt | 12 +++++++++++- .../views/video/FutoVideoPlayerBase.kt | 12 ++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) 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 c7bf3e5c..d264a7ad 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 @@ -478,7 +478,17 @@ class FutoVideoPlayer : FutoVideoPlayerBase { updateAutoplayButton() val progressUpdateListener = { position: Long, bufferedPosition: Long -> - val currentTime = position.formatDuration() + // For live streams that have been seeked behind, replace the running position with + // a -MM:SS "behind live" indicator (the videojs/HLS convention). At the live edge + // we keep showing the running position; this matches YouTube's web behaviour where + // the LIVE pill alone (red "caught up" / gray "behind") + a clear offset readout + // tell the whole story. + val behindMs = if (isLive) behindLiveMs else null + val currentTime = if (behindMs != null && behindMs > 0) { + "-" + behindMs.formatDuration() + } else { + position.formatDuration() + } val currentDuration = duration.formatDuration() _control_time.text = currentTime; _control_time_fullscreen.text = currentTime; 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 af5ce802..bc24397e 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 @@ -176,6 +176,18 @@ abstract class FutoVideoPlayerBase : RelativeLayout { } } + /** + * How far the player is behind the live edge from a user perspective, in ms. Subtracts the + * manifest's natural live offset (or the [LIVE_EDGE_FALLBACK_THRESHOLD_MS] when unknown) so + * the value reflects the user-perceptible delay rather than the inherent HLS/DASH latency. + * Returns null when not live or the offset is unknown; returns 0 when at the live edge. + */ + val behindLiveMs: Long? get() { + val offset = liveOffsetMs ?: return null + val baseline = targetLiveOffsetMs ?: LIVE_EDGE_FALLBACK_THRESHOLD_MS + return (offset - baseline).coerceAtLeast(0) + } + var isAudioMode: Boolean = false private set;