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.
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.
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).
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.
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.