mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Merge branch 'pr/improve-live-functionality' of https://github.com/Titaniumtown/grayjay-android
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -225,24 +241,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();
|
||||
@@ -460,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;
|
||||
@@ -473,6 +501,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)
|
||||
@@ -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();
|
||||
|
||||
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) {
|
||||
gestureControl.setSoundFactor(soundFactor);
|
||||
}
|
||||
|
||||
@@ -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,64 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -136,6 +195,8 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
val onStateChange = Event1<Int>();
|
||||
val onPositionDiscontinuity = Event1<Long>();
|
||||
val onDatasourceError = Event1<Throwable>();
|
||||
/** Emits when live state (live vs not) of the current media item changes. */
|
||||
val onLiveChanged = Event1<Boolean>();
|
||||
|
||||
val onReloadRequired = Event0();
|
||||
|
||||
@@ -150,6 +211,21 @@ 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
|
||||
|
||||
/** 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)
|
||||
@@ -217,6 +293,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 +415,91 @@ 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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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?) {
|
||||
exoPlayer?.modifyState(exoPlayerStateName, {state -> state.listener = null});
|
||||
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 {
|
||||
// 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)
|
||||
val swapId = _swapIdVideo.incrementAndGet()
|
||||
_lastGeneratedDash = null;
|
||||
@@ -989,6 +1180,9 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
_lastAudioMediaSource = null;
|
||||
_lastSubtitleMediaSource = null;
|
||||
_mediaSource = null;
|
||||
_isLiveSession = false
|
||||
_liveReloadAttempts = 0
|
||||
_lastLiveReloadAt_ms = 0
|
||||
}
|
||||
|
||||
fun stop(){
|
||||
@@ -1013,18 +1207,34 @@ 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;
|
||||
}
|
||||
|
||||
// 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}");
|
||||
@@ -1043,7 +1253,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,
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
@@ -1074,6 +1300,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) { }
|
||||
@@ -1092,6 +1329,21 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
|
||||
val PREFERED_AUDIO_CONTAINERS: Array<String> 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
|
||||
/** 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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -195,6 +195,47 @@
|
||||
app:layout_constraintTop_toTopOf="@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
|
||||
android:id="@+id/text_chapter_current"
|
||||
android:layout_width="0dp"
|
||||
@@ -204,7 +245,7 @@
|
||||
android:paddingRight="10dp"
|
||||
android:textSize="11sp"
|
||||
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_constraintBottom_toBottomOf="@id/text_duration"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
|
||||
|
||||
@@ -225,6 +225,45 @@
|
||||
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
|
||||
android:id="@+id/text_chapter_current"
|
||||
android:layout_width="0dp"
|
||||
@@ -234,7 +273,7 @@
|
||||
android:layout_marginTop="-2dp"
|
||||
android:textSize="11sp"
|
||||
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_constraintBottom_toBottomOf="@id/text_duration"
|
||||
app:layout_constraintRight_toLeftOf="@id/button_fullscreen"
|
||||
|
||||
@@ -952,6 +952,7 @@
|
||||
<string name="cd_button_stop">Stop</string>
|
||||
<string name="cd_button_scan_qr">Scan QR code</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-array name="home_screen_array">
|
||||
<item>Recommendations</item>
|
||||
|
||||
Reference in New Issue
Block a user