Skip to content

Commit adf2ed6

Browse files
Fix livestreams (#2627)
1 parent b89f36c commit adf2ed6

9 files changed

Lines changed: 333 additions & 6 deletions

File tree

app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import androidx.media3.datasource.cronet.CronetDataSource
4242
import androidx.media3.datasource.okhttp.OkHttpDataSource
4343
import androidx.media3.exoplayer.DecoderCounters
4444
import androidx.media3.exoplayer.DecoderReuseEvaluation
45+
import androidx.media3.exoplayer.DefaultLivePlaybackSpeedControl
4546
import androidx.media3.exoplayer.DefaultLoadControl
4647
import androidx.media3.exoplayer.DefaultRenderersFactory
4748
import androidx.media3.exoplayer.ExoPlayer
@@ -54,6 +55,7 @@ import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
5455
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
5556
import androidx.media3.exoplayer.drm.HttpMediaDrmCallback
5657
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
58+
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker
5759
import androidx.media3.exoplayer.source.ClippingMediaSource
5860
import androidx.media3.exoplayer.source.ConcatenatingMediaSource
5961
import androidx.media3.exoplayer.source.ConcatenatingMediaSource2
@@ -83,6 +85,8 @@ import com.lagradost.cloudstream3.mvvm.debugAssert
8385
import com.lagradost.cloudstream3.mvvm.logError
8486
import com.lagradost.cloudstream3.mvvm.safe
8587
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment
88+
import com.lagradost.cloudstream3.ui.player.live.LiveHelper
89+
import com.lagradost.cloudstream3.ui.player.live.PREFERRED_LIVE_OFFSET
8690
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
8791
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
8892
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
@@ -272,6 +276,10 @@ class CS3IPlayer : IPlayer {
272276
}
273277

274278
override fun hasPreview(): Boolean {
279+
// No previews on livestreams because the previews get outdated
280+
if (exoPlayer?.isCurrentMediaItemDynamic == true) {
281+
return false
282+
}
275283
return imageGenerator.hasPreview()
276284
}
277285

@@ -399,7 +407,12 @@ class CS3IPlayer : IPlayer {
399407
?.let { group ->
400408
exoPlayer?.trackSelectionParameters
401409
?.buildUpon()
402-
?.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, trackFormatIndex))
410+
?.setOverrideForType(
411+
TrackSelectionOverride(
412+
group.mediaTrackGroup,
413+
trackFormatIndex
414+
)
415+
)
403416
?.build()
404417
}
405418
?.let { newParams ->
@@ -516,10 +529,12 @@ class CS3IPlayer : IPlayer {
516529
Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD")
517530
return true
518531
}
532+
519533
SubtitleStatus.NOT_FOUND -> {
520534
Log.i(TAG, "setPreferredSubtitles NOT_FOUND")
521535
return true
522536
}
537+
523538
SubtitleStatus.IS_ACTIVE -> {
524539
Log.i(TAG, "setPreferredSubtitles IS_ACTIVE")
525540
exoPlayer?.currentTracks?.groups
@@ -1067,6 +1082,17 @@ class CS3IPlayer : IPlayer {
10671082
): ExoPlayer {
10681083
val exoPlayerBuilder =
10691084
ExoPlayer.Builder(context)
1085+
.setMediaSourceFactory(
1086+
DefaultMediaSourceFactory(context).setLiveTargetOffsetMs(
1087+
PREFERRED_LIVE_OFFSET
1088+
)
1089+
)
1090+
.setLivePlaybackSpeedControl(
1091+
DefaultLivePlaybackSpeedControl.Builder()
1092+
.setFallbackMaxPlaybackSpeed(1.03f)
1093+
.setFallbackMinPlaybackSpeed(0.97f)
1094+
.build()
1095+
)
10701096
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
10711097
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
10721098
val current = settingsManager.getInt(
@@ -1398,6 +1424,8 @@ class CS3IPlayer : IPlayer {
13981424
return
13991425
}
14001426

1427+
LiveHelper.registerPlayer(exoPlayer)
1428+
14011429
exoPlayer?.addListener(object : Player.Listener {
14021430
override fun onTracksChanged(tracks: Tracks) {
14031431
safe {
@@ -1506,6 +1534,23 @@ class CS3IPlayer : IPlayer {
15061534
exoPlayer?.prepare()
15071535
}
15081536

1537+
// PlaylistStuckException usually happens when the player position is ahead of the live window.
1538+
// Seek to the default location in that case
1539+
error.cause is HlsPlaylistTracker.PlaylistStuckException -> {
1540+
val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0
1541+
1542+
// Seek to live head
1543+
val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0
1544+
1545+
if (aheadOfLive > 100) {
1546+
exoPlayer?.seekTo(position - aheadOfLive)
1547+
} else {
1548+
exoPlayer?.seekToDefaultPosition()
1549+
}
1550+
exoPlayer?.prepare()
1551+
}
1552+
1553+
15091554
else -> {
15101555
event(ErrorEvent(error))
15111556
}

app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
631631
override fun subtitlesChanged() {
632632
val tracks = player.getVideoTracks()
633633
val isBuiltinSubtitles = tracks.currentTextTracks.all { track ->
634-
track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES
634+
track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES
635635
}
636636
// Subtitle offset is not possible on built-in media3 tracks
637637
playerBinding?.playerSubtitleOffsetBtt?.isGone =
@@ -738,6 +738,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
738738
activity?.window?.attributes = lp
739739
activity?.showSystemUI()
740740
}
741+
741742
private fun resetZoomToDefault() {
742743
if (zoomMatrix != null) resize(PlayerResize.Fit, false)
743744
}
@@ -2648,6 +2649,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
26482649
}
26492650
}
26502651

2652+
exoProgress.registerPlayerView(playerView)
2653+
26512654
exoProgress.setOnTouchListener { _, event ->
26522655
// this makes the bar not disappear when sliding
26532656
when (event.action) {
@@ -2720,10 +2723,20 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
27202723
val duration = player.getDuration()
27212724
val position = player.getPosition()
27222725

2726+
if (playerBinding?.exoProgress?.isAtLiveEdge() == true) {
2727+
// Hide using a parentView instead?
2728+
playerBinding?.timeLeft?.alpha = 0f
2729+
playerBinding?.exoDuration?.alpha = 0f
2730+
playerBinding?.timeLive?.isVisible = true
2731+
} else {
2732+
playerBinding?.timeLeft?.alpha = 1f
2733+
playerBinding?.exoDuration?.alpha = 1f
2734+
playerBinding?.timeLive?.isVisible = false
2735+
}
2736+
27232737
if (duration != null && duration > 1 && position != null) {
27242738
val remainingTimeSeconds = (duration - position + 500) / 1000
27252739
val formattedTime = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}"
2726-
27272740
playerBinding?.timeLeft?.text = formattedTime
27282741
}
27292742
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.lagradost.cloudstream3.ui.player.live
2+
3+
import androidx.annotation.OptIn
4+
import androidx.media3.common.Player
5+
import androidx.media3.common.Timeline
6+
import androidx.media3.common.util.UnstableApi
7+
import com.lagradost.cloudstream3.mvvm.debugWarning
8+
import java.util.WeakHashMap
9+
10+
object LiveHelper {
11+
private val liveManagers = WeakHashMap<Player, Pair<LiveManager, Player.Listener>>()
12+
13+
@OptIn(UnstableApi::class)
14+
fun registerPlayer(player: Player?) {
15+
if (player == null) {
16+
debugWarning { "LiveHelper registerPlayer called with null player!" }
17+
return
18+
}
19+
20+
// Prevent duplicates
21+
if (liveManagers.contains(player)) {
22+
return
23+
}
24+
25+
val liveManager = LiveManager(player)
26+
val listener = object : Player.Listener {
27+
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
28+
val window = Timeline.Window()
29+
timeline.getWindow(player.currentMediaItemIndex, window)
30+
if (window.isDynamic) {
31+
liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs))
32+
}
33+
super.onTimelineChanged(timeline, reason)
34+
}
35+
36+
override fun onPositionDiscontinuity(
37+
oldPosition: Player.PositionInfo,
38+
newPosition: Player.PositionInfo,
39+
reason: Int
40+
) {
41+
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
42+
val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs)
43+
44+
// Seek back to the optimal live spot
45+
if (timeAheadOfLive > 100) {
46+
player.seekTo(newPosition.positionMs - timeAheadOfLive)
47+
}
48+
}
49+
}
50+
51+
synchronized(liveManagers) {
52+
player.addListener(listener)
53+
liveManagers[player] = liveManager to listener
54+
}
55+
}
56+
57+
fun unregisterPlayer(player: Player?) {
58+
if (player == null) {
59+
debugWarning { "LiveHelper unregisterPlayer called with null player!" }
60+
return
61+
}
62+
63+
// Prevent duplicates
64+
if (!liveManagers.contains(player)) {
65+
return
66+
}
67+
68+
synchronized(liveManagers) {
69+
liveManagers[player]?.let { (_, listener) ->
70+
player.removeListener(listener)
71+
}
72+
liveManagers.remove(player)
73+
}
74+
}
75+
76+
fun getLiveManager(player: Player?) = liveManagers[player]?.first
77+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.lagradost.cloudstream3.ui.player.live
2+
3+
import androidx.media3.common.C
4+
import androidx.media3.common.Player
5+
import java.lang.ref.WeakReference
6+
7+
// How much margin from the live point is still considered "live"
8+
const val LIVE_MARGIN = 6_000L
9+
10+
// How many ms should we be behind the real live point?
11+
// Too low, and we cannot pre-buffer
12+
// Too high, and we are no longer live
13+
const val PREFERRED_LIVE_OFFSET = 5_000L
14+
15+
// An extra offset from the optimal calculated timestamp
16+
// This is to account for chunk updates not always being the same size
17+
const val CHUNK_VARIANCE = 3000L
18+
19+
// A livestream chunk from the player, the time we get it and the duration can be used to calculate
20+
// the expected live timestamp.
21+
class LivestreamChunk(
22+
durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis()
23+
) {
24+
// We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point.
25+
// If we are ahead of the middle point we will reach the end before the new chunk is expected to be released.
26+
val targetPosition = maxOf(0,minOf(
27+
durationMs - PREFERRED_LIVE_OFFSET,
28+
durationMs / 2 - CHUNK_VARIANCE
29+
))
30+
31+
fun isPositionLive(position: Long): Boolean {
32+
val currentTime = System.currentTimeMillis()
33+
val livePosition = targetPosition + (currentTime - receiveTimeMs)
34+
val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET
35+
// println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive")
36+
return withinLive
37+
}
38+
39+
fun getTimeAheadOfLive(position: Long): Long {
40+
val currentTime = System.currentTimeMillis()
41+
val livePosition = targetPosition + (currentTime - receiveTimeMs)
42+
// println("Ahead of live: ${position-livePosition}")
43+
return position - livePosition
44+
}
45+
}
46+
47+
// There are two types of livestreams we need to manage
48+
// 1. A livestream with no history, a continually sliding window.
49+
// This livestream has no currentLiveOffset, which means we need to calculate
50+
// the real live point based on when we receive the latest update and the size of that update.
51+
// 2. A livestream with history.
52+
// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point.
53+
// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations.
54+
class LiveManager {
55+
private var _currentPlayer: WeakReference<Player>? = null
56+
val currentPlayer: Player? get() = _currentPlayer?.get()
57+
58+
constructor(player: Player?) {
59+
_currentPlayer = WeakReference(player)
60+
}
61+
62+
private var lastLivestreamChunk: LivestreamChunk? = null
63+
64+
fun submitLivestreamChunk(chunk: LivestreamChunk) {
65+
lastLivestreamChunk = chunk
66+
}
67+
68+
/** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */
69+
fun getTimeAheadOfLive(position: Long): Long {
70+
val player = currentPlayer ?: return 0
71+
if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0
72+
73+
// If the currentLiveOffset is wrong we fall back to manual calculations
74+
val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) {
75+
val relativeOffset = player.currentLiveOffset - player.currentPosition + position
76+
PREFERRED_LIVE_OFFSET - relativeOffset
77+
} else {
78+
lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0
79+
}
80+
81+
// Ensure min of 0
82+
return maxOf(0, ahead)
83+
}
84+
85+
/** Check if the stream is currently at the expected live edge, with margins */
86+
fun isAtLiveEdge(): Boolean {
87+
val player = currentPlayer ?: return false
88+
if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false
89+
90+
// If the currentLiveOffset is wrong we fall back to manual calculations
91+
return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) {
92+
player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET
93+
} else {
94+
lastLivestreamChunk?.isPositionLive(player.currentPosition) == true
95+
}
96+
}
97+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.lagradost.cloudstream3.ui.player.live
2+
3+
import android.content.Context
4+
import android.util.AttributeSet
5+
import androidx.annotation.OptIn
6+
import androidx.media3.common.Player
7+
import androidx.media3.common.util.UnstableApi
8+
import androidx.media3.ui.PlayerControlView
9+
import androidx.media3.ui.PlayerView
10+
import androidx.media3.ui.R
11+
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
12+
import java.lang.ref.WeakReference
13+
14+
15+
@OptIn(UnstableApi::class)
16+
class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) {
17+
18+
private var _currentPlayerView: WeakReference<PlayerView>? = null
19+
val currentPlayer: Player? get() = _currentPlayerView?.get()?.player
20+
21+
fun registerPlayerView(player: PlayerView?) {
22+
_currentPlayerView = WeakReference(player)
23+
val controller =
24+
_currentPlayerView?.get()?.findViewById<PlayerControlView>(R.id.exo_controller)
25+
26+
controller?.setProgressUpdateListener { position, bufferedPosition ->
27+
currentPlayer?.let { player ->
28+
if (isAtLiveEdge()) {
29+
setPosition(player.duration)
30+
}
31+
}
32+
}
33+
}
34+
35+
fun isAtLiveEdge(): Boolean {
36+
return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true
37+
}
38+
}

0 commit comments

Comments
 (0)