Merge branch 'pr/improve-live-functionality' of https://github.com/Titaniumtown/grayjay-android

This commit is contained in:
Koen J
2026-05-08 11:04:50 +02:00
10 changed files with 489 additions and 21 deletions
@@ -251,6 +251,11 @@ fun String.fixHtmlWhitespace(): Spanned {
} }
fun Long.formatDuration(): String { 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 hours = this / 3600000
val minutes = (this % 3600000) / 60000 val minutes = (this % 3600000) / 60000
val seconds = (this % 60000) / 1000 val seconds = (this % 60000) / 1000
@@ -18,6 +18,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import androidx.annotation.OptIn import androidx.annotation.OptIn
@@ -119,6 +120,15 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
private val _control_duration_fullscreen: TextView; private val _control_duration_fullscreen: TextView;
private val _control_pause_fullscreen: ImageButton; 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 _title_fullscreen: TextView;
private val _author_fullscreen: TextView; private val _author_fullscreen: TextView;
private var _shouldRestartHideJobOnPlaybackStateChange: Boolean = false; private var _shouldRestartHideJobOnPlaybackStateChange: Boolean = false;
@@ -189,6 +199,9 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_buttonPrevious = videoControls.findViewById(R.id.button_previous); _buttonPrevious = videoControls.findViewById(R.id.button_previous);
_control_time = videoControls.findViewById(R.id.text_position); _control_time = videoControls.findViewById(R.id.text_position);
_control_duration = videoControls.findViewById(R.id.text_duration); _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); _videoControls_fullscreen = findViewById(R.id.video_player_controller_fullscreen);
_control_autoplay_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_autoplay); _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_time_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_position);
_control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration); _control_duration_fullscreen = _videoControls_fullscreen.findViewById(R.id.text_duration);
_control_pause_fullscreen = _videoControls_fullscreen.findViewById(R.id.button_pause); _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 = findViewById(R.id.loader_overlay)
_loaderGame.visibility = View.GONE _loaderGame.visibility = View.GONE
@@ -225,24 +241,26 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_buttonNext.setOnClickListener { onNext.emit() }; _buttonNext.setOnClickListener { onNext.emit() };
_buttonPrevious_fullscreen.setOnClickListener { onPrevious.emit() }; _buttonPrevious_fullscreen.setOnClickListener { onPrevious.emit() };
_buttonNext_fullscreen.setOnClickListener { onNext.emit() }; _buttonNext_fullscreen.setOnClickListener { onNext.emit() };
_control_play.setOnClickListener { val playClickHandler = View.OnClickListener {
exoPlayer?.player?.let { // Order matters:
if (it.contentPosition >= it.duration) { // 1. If the player is stuck (STATE_IDLE after error, STATE_ENDED on a slipped live
it.seekTo(0) // 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(); updatePlayPause()
}; }
_control_play_fullscreen.setOnClickListener { _control_play.setOnClickListener(playClickHandler)
exoPlayer?.player?.let { _control_play_fullscreen.setOnClickListener(playClickHandler)
if (it.contentPosition >= it.duration) {
it.seekTo(0)
}
exoPlayer?.player?.play();
}
updatePlayPause();
};
_control_pause.setOnClickListener { _control_pause.setOnClickListener {
exoPlayer?.player?.pause(); exoPlayer?.player?.pause();
updatePlayPause(); updatePlayPause();
@@ -460,7 +478,17 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
updateAutoplayButton() updateAutoplayButton()
val progressUpdateListener = { position: Long, bufferedPosition: Long -> 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() val currentDuration = duration.formatDuration()
_control_time.text = currentTime; _control_time.text = currentTime;
_control_time_fullscreen.text = currentTime; _control_time_fullscreen.text = currentTime;
@@ -473,6 +501,12 @@ class FutoVideoPlayer : FutoVideoPlayerBase {
_time_bar_fullscreen.setBufferedPosition(bufferedPosition); _time_bar_fullscreen.setBufferedPosition(bufferedPosition);
_time_bar.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); onTimeBarChanged.emit(position, bufferedPosition);
if(!_currentChapterLoopActive) if(!_currentChapterLoopActive)
@@ -499,6 +533,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(); updateLoopVideoUI();
if(!isInEditMode) { if(!isInEditMode) {
@@ -895,6 +946,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) { fun setGestureSoundFactor(soundFactor: Float) {
gestureControl.setSoundFactor(soundFactor); gestureControl.setSoundFactor(soundFactor);
} }
@@ -17,6 +17,7 @@ import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.VideoSize import androidx.media3.common.VideoSize
import androidx.media3.common.text.CueGroup import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
@@ -129,6 +130,64 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val position: Long get() = exoPlayer?.player?.currentPosition ?: 0; val position: Long get() = exoPlayer?.player?.currentPosition ?: 0;
val duration: Long get() = exoPlayer?.player?.duration ?: 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
}
}
/**
* 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 var isAudioMode: Boolean = false
private set; private set;
@@ -136,6 +195,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val onStateChange = Event1<Int>(); val onStateChange = Event1<Int>();
val onPositionDiscontinuity = Event1<Long>(); val onPositionDiscontinuity = Event1<Long>();
val onDatasourceError = Event1<Throwable>(); val onDatasourceError = Event1<Throwable>();
/** Emits when live state (live vs not) of the current media item changes. */
val onLiveChanged = Event1<Boolean>();
val onReloadRequired = Event0(); val onReloadRequired = Event0();
@@ -150,6 +211,21 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
private var _toResume = false; 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
/** 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 { private val _playerEventListener = object: Player.Listener {
override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) { override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) {
super.onPlaybackSuppressionReasonChanged(playbackSuppressionReason) super.onPlaybackSuppressionReasonChanged(playbackSuppressionReason)
@@ -217,6 +293,30 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
Logger.i(TAG, "CUE GROUP: ${cueGroup.cues.firstOrNull()?.text}"); 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) { override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error); super.onPlayerError(error);
this@FutoVideoPlayerBase.onPlayerError(error); this@FutoVideoPlayerBase.onPlayerError(error);
@@ -315,6 +415,91 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
exoPlayer?.player?.seekTo(Math.min(to, exoPlayer?.player?.duration ?: to)); 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()
}
/**
* 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
* 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?) { fun changePlayer(newPlayer: PlayerManager?) {
exoPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = null}); exoPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = null});
newPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = _playerEventListener}); newPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = _playerEventListener});
@@ -476,6 +661,12 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean { 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
_liveReloadAttempts = 0
_lastLiveReloadAt_ms = 0
setLoading(false) setLoading(false)
val swapId = _swapIdVideo.incrementAndGet() val swapId = _swapIdVideo.incrementAndGet()
_lastGeneratedDash = null; _lastGeneratedDash = null;
@@ -989,6 +1180,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
_lastAudioMediaSource = null; _lastAudioMediaSource = null;
_lastSubtitleMediaSource = null; _lastSubtitleMediaSource = null;
_mediaSource = null; _mediaSource = null;
_isLiveSession = false
_liveReloadAttempts = 0
_lastLiveReloadAt_ms = 0
} }
fun stop(){ fun stop(){
@@ -1013,18 +1207,34 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
protected open fun onPlayerError(error: PlaybackException) { protected open fun onPlayerError(error: PlaybackException) {
Logger.i(TAG, "onPlayerError error=$error error.errorCode=${error.errorCode} connectivityLoss, cause=${error.cause}"); 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); Logger.e(TAG, "BehindLiveWindowException, " + error.message);
reloadMediaSource(true, true); reloadMediaSource(true, true);
exoPlayer?.player?.seekToDefaultPosition();
return; return;
} }
if(error != null && error.cause is HlsPlaylistTracker.PlaylistStuckException) { if(error != null && error.cause is HlsPlaylistTracker.PlaylistStuckException) {
Logger.e(TAG, "PlaylistStuckException"); Logger.e(TAG, "PlaylistStuckException");
reloadMediaSource(true, true); reloadMediaSource(true, true);
exoPlayer?.player?.seekToDefaultPosition();
UIDialogs.toast("Live playback error, reloading.."); UIDialogs.toast("Live playback error, reloading..");
return; 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) { when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> { 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}"); Logger.w(TAG, "ERROR_CODE_IO_BAD_HTTP_STATUS ${error.cause?.javaClass?.simpleName}");
@@ -1043,7 +1253,6 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
//PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, //PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
//PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, //PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
//PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, //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_NETWORK_CONNECTION_TIMEOUT,
//PlaybackException.ERROR_CODE_IO_NO_PERMISSION, //PlaybackException.ERROR_CODE_IO_NO_PERMISSION,
//PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, //PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
@@ -1058,6 +1267,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) { protected open fun onVideoSizeChanged(videoSize: VideoSize) {
} }
@@ -1074,6 +1300,17 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
Logger.i(TAG, "_shouldPlaybackRestartOnConnectivity=false"); Logger.i(TAG, "_shouldPlaybackRestartOnConnectivity=false");
_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) { } protected open fun setLoading(isLoading: Boolean) { }
@@ -1092,6 +1329,21 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
val PREFERED_AUDIO_CONTAINERS: Array<String> get() { return if(Settings.instance.playback.preferWebmAudio) val PREFERED_AUDIO_CONTAINERS: Array<String> get() { return if(Settings.instance.playback.preferWebmAudio)
PREFERED_AUDIO_CONTAINERS_WEBMPref else PREFERED_AUDIO_CONTAINERS_MP4Pref } 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
/** 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"); val SUPPORTED_SUBTITLES = hashSetOf("text/vtt", "application/x-subrip");
} }
} }
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- LIVE pill background: solid red when at the live edge. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#DDFF0000" />
<corners android:radius="3dp" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- LIVE pill background while behind the live edge: muted gray, signaling tap-to-jump. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#88555555" />
<corners android:radius="3dp" />
<stroke android:width="1dp" android:color="#FFAAAAAA" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Hollow / muted dot rendered while seeked behind the live edge. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFAAAAAA" />
<size android:width="6dp" android:height="6dp" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- White dot rendered on top of the red LIVE pill while at the edge. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FFFFFFFF" />
<size android:width="6dp" android:height="6dp" />
</shape>
+42 -1
View File
@@ -195,6 +195,47 @@
app:layout_constraintTop_toTopOf="@id/text_position" app:layout_constraintTop_toTopOf="@id/text_position"
app:layout_constraintBottom_toBottomOf="@id/text_position"/> app:layout_constraintBottom_toBottomOf="@id/text_position"/>
<!-- LIVE pill: hidden by default; shown by FutoVideoPlayer when isLive=true.
Constrained next to the duration text so it occupies the same row but does not
reflow when toggled (alpha/visibility only). Tap to jump to live edge. -->
<LinearLayout
android:id="@+id/live_pill_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/background_live_pill"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:layout_marginStart="6dp"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:contentDescription="@string/cd_button_jump_to_live"
app:layout_constraintLeft_toRightOf="@id/text_duration"
app:layout_constraintTop_toTopOf="@id/text_position"
app:layout_constraintBottom_toBottomOf="@id/text_position">
<View
android:id="@+id/live_pill_dot"
android:layout_width="6dp"
android:layout_height="6dp"
android:layout_marginEnd="4dp"
android:background="@drawable/dot_live_edge" />
<TextView
android:id="@+id/live_pill_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:text="@string/live_capitalized"
android:textColor="#FFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
<TextView <TextView
android:id="@+id/text_chapter_current" android:id="@+id/text_chapter_current"
android:layout_width="0dp" android:layout_width="0dp"
@@ -204,7 +245,7 @@
android:paddingRight="10dp" android:paddingRight="10dp"
android:textSize="11sp" android:textSize="11sp"
android:gravity="left" android:gravity="left"
app:layout_constraintLeft_toRightOf="@id/text_duration" app:layout_constraintLeft_toRightOf="@id/live_pill_container"
app:layout_constraintTop_toTopOf="@id/text_duration" app:layout_constraintTop_toTopOf="@id/text_duration"
app:layout_constraintBottom_toBottomOf="@id/text_duration" app:layout_constraintBottom_toBottomOf="@id/text_duration"
app:layout_constraintRight_toLeftOf="@id/button_fullscreen" app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
@@ -225,6 +225,45 @@
app:layout_constraintBottom_toBottomOf="@id/text_position"/> app:layout_constraintBottom_toBottomOf="@id/text_position"/>
<!-- LIVE pill: hidden by default; shown by FutoVideoPlayer when isLive=true. -->
<LinearLayout
android:id="@+id/live_pill_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@drawable/background_live_pill"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:layout_marginStart="6dp"
android:visibility="gone"
android:clickable="true"
android:focusable="true"
android:contentDescription="@string/cd_button_jump_to_live"
app:layout_constraintLeft_toRightOf="@id/text_duration"
app:layout_constraintTop_toTopOf="@id/text_position"
app:layout_constraintBottom_toBottomOf="@id/text_position">
<View
android:id="@+id/live_pill_dot"
android:layout_width="6dp"
android:layout_height="6dp"
android:layout_marginEnd="4dp"
android:background="@drawable/dot_live_edge" />
<TextView
android:id="@+id/live_pill_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:includeFontPadding="false"
android:text="@string/live_capitalized"
android:textColor="#FFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
<TextView <TextView
android:id="@+id/text_chapter_current" android:id="@+id/text_chapter_current"
android:layout_width="0dp" android:layout_width="0dp"
@@ -234,7 +273,7 @@
android:layout_marginTop="-2dp" android:layout_marginTop="-2dp"
android:textSize="11sp" android:textSize="11sp"
android:gravity="left" android:gravity="left"
app:layout_constraintLeft_toRightOf="@id/text_duration" app:layout_constraintLeft_toRightOf="@id/live_pill_container"
app:layout_constraintTop_toTopOf="@id/text_duration" app:layout_constraintTop_toTopOf="@id/text_duration"
app:layout_constraintBottom_toBottomOf="@id/text_duration" app:layout_constraintBottom_toBottomOf="@id/text_duration"
app:layout_constraintRight_toLeftOf="@id/button_fullscreen" app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
+1
View File
@@ -952,6 +952,7 @@
<string name="cd_button_stop">Stop</string> <string name="cd_button_stop">Stop</string>
<string name="cd_button_scan_qr">Scan QR code</string> <string name="cd_button_scan_qr">Scan QR code</string>
<string name="cd_button_help">Help</string> <string name="cd_button_help">Help</string>
<string name="cd_button_jump_to_live">Jump to live edge</string>
<string name="cd_image_polycentric">Change Polycentric profile picture</string> <string name="cd_image_polycentric">Change Polycentric profile picture</string>
<string-array name="home_screen_array"> <string-array name="home_screen_array">
<item>Recommendations</item> <item>Recommendations</item>