Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import androidx.media3.datasource.cronet.CronetDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DecoderCounters
import androidx.media3.exoplayer.DecoderReuseEvaluation
import androidx.media3.exoplayer.DefaultLivePlaybackSpeedControl
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
Expand All @@ -54,6 +55,7 @@ import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
import androidx.media3.exoplayer.drm.HttpMediaDrmCallback
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker
import androidx.media3.exoplayer.source.ClippingMediaSource
import androidx.media3.exoplayer.source.ConcatenatingMediaSource
import androidx.media3.exoplayer.source.ConcatenatingMediaSource2
Expand Down Expand Up @@ -83,6 +85,8 @@ import com.lagradost.cloudstream3.mvvm.debugAssert
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.safe
import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment
import com.lagradost.cloudstream3.ui.player.live.LiveHelper
import com.lagradost.cloudstream3.ui.player.live.PREFERRED_LIVE_OFFSET
import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR
import com.lagradost.cloudstream3.ui.settings.Globals.PHONE
import com.lagradost.cloudstream3.ui.settings.Globals.isLayout
Expand All @@ -102,7 +106,6 @@ import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.PLAYREADY_UUID
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName
import com.lagradost.cloudstream3.utils.WIDEVINE_UUID
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
import kotlinx.coroutines.delay
import okhttp3.Interceptor
import org.chromium.net.CronetEngine
Expand Down Expand Up @@ -273,6 +276,10 @@ class CS3IPlayer : IPlayer {
}

override fun hasPreview(): Boolean {
// No previews on livestreams because the previews get outdated
if (exoPlayer?.isCurrentMediaItemDynamic == true) {
return false
}
return imageGenerator.hasPreview()
}

Expand Down Expand Up @@ -400,7 +407,12 @@ class CS3IPlayer : IPlayer {
?.let { group ->
exoPlayer?.trackSelectionParameters
?.buildUpon()
?.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, trackFormatIndex))
?.setOverrideForType(
TrackSelectionOverride(
group.mediaTrackGroup,
trackFormatIndex
)
)
?.build()
}
?.let { newParams ->
Expand Down Expand Up @@ -517,10 +529,12 @@ class CS3IPlayer : IPlayer {
Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD")
return true
}

SubtitleStatus.NOT_FOUND -> {
Log.i(TAG, "setPreferredSubtitles NOT_FOUND")
return true
}

SubtitleStatus.IS_ACTIVE -> {
Log.i(TAG, "setPreferredSubtitles IS_ACTIVE")
exoPlayer?.currentTracks?.groups
Expand Down Expand Up @@ -1068,6 +1082,17 @@ class CS3IPlayer : IPlayer {
): ExoPlayer {
val exoPlayerBuilder =
ExoPlayer.Builder(context)
.setMediaSourceFactory(
DefaultMediaSourceFactory(context).setLiveTargetOffsetMs(
PREFERRED_LIVE_OFFSET
)
)
.setLivePlaybackSpeedControl(
DefaultLivePlaybackSpeedControl.Builder()
.setFallbackMaxPlaybackSpeed(1.03f)
.setFallbackMinPlaybackSpeed(0.97f)
.build()
)
.setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput ->
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
val current = settingsManager.getInt(
Expand Down Expand Up @@ -1399,6 +1424,8 @@ class CS3IPlayer : IPlayer {
return
}

LiveHelper.registerPlayer(exoPlayer)

exoPlayer?.addListener(object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
safe {
Expand Down Expand Up @@ -1507,6 +1534,23 @@ class CS3IPlayer : IPlayer {
exoPlayer?.prepare()
}

// PlaylistStuckException usually happens when the player position is ahead of the live window.
// Seek to the default location in that case
error.cause is HlsPlaylistTracker.PlaylistStuckException -> {
val position = exoPlayer?.currentPosition ?: exoPlayer?.duration ?: 0

// Seek to live head
val aheadOfLive = LiveHelper.getLiveManager(exoPlayer)?.getTimeAheadOfLive(position) ?: 0

if (aheadOfLive > 100) {
exoPlayer?.seekTo(position - aheadOfLive)
} else {
exoPlayer?.seekToDefaultPosition()
}
exoPlayer?.prepare()
}


else -> {
event(ErrorEvent(error))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
override fun subtitlesChanged() {
val tracks = player.getVideoTracks()
val isBuiltinSubtitles = tracks.currentTextTracks.all { track ->
track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES
track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES
}
// Subtitle offset is not possible on built-in media3 tracks
playerBinding?.playerSubtitleOffsetBtt?.isGone =
Expand Down Expand Up @@ -738,6 +738,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
activity?.window?.attributes = lp
activity?.showSystemUI()
}

private fun resetZoomToDefault() {
if (zoomMatrix != null) resize(PlayerResize.Fit, false)
}
Expand Down Expand Up @@ -2648,6 +2649,8 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
}
}

exoProgress.registerPlayerView(playerView)

exoProgress.setOnTouchListener { _, event ->
// this makes the bar not disappear when sliding
when (event.action) {
Expand Down Expand Up @@ -2715,10 +2718,20 @@ open class FullScreenPlayer : AbstractPlayerFragment() {
val duration = player.getDuration()
val position = player.getPosition()

if (playerBinding?.exoProgress?.isAtLiveEdge() == true) {
// Hide using a parentView instead?
playerBinding?.timeLeft?.alpha = 0f
playerBinding?.exoDuration?.alpha = 0f
playerBinding?.timeLive?.isVisible = true
} else {
playerBinding?.timeLeft?.alpha = 1f
playerBinding?.exoDuration?.alpha = 1f
playerBinding?.timeLive?.isVisible = false
}

if (duration != null && duration > 1 && position != null) {
val remainingTimeSeconds = (duration - position + 500) / 1000
val formattedTime = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}"

playerBinding?.timeLeft?.text = formattedTime
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.lagradost.cloudstream3.ui.player.live

import androidx.annotation.OptIn
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import com.lagradost.cloudstream3.mvvm.debugWarning
import java.util.WeakHashMap

object LiveHelper {
private val liveManagers = WeakHashMap<Player, LiveManager>()
private val listeners = WeakHashMap<Player, Player.Listener>()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would make more sense as a Pair, to make them synced.


@OptIn(UnstableApi::class)

fun registerPlayer(player: Player?) {
if (player == null) {
debugWarning { "LiveHelper registerPlayer called with null player!" }
return
}

// Prevent duplicates
if (liveManagers.contains(player) || listeners.contains(player)) {
return
}

val liveManager = LiveManager(player)
val listener = object : Player.Listener {
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
val window = Timeline.Window()
timeline.getWindow(player.currentMediaItemIndex, window)
if (window.isDynamic) {
liveManager.submitLivestreamChunk(LivestreamChunk(window.durationMs))
}
super.onTimelineChanged(timeline, reason)
}

override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
val timeAheadOfLive = liveManager.getTimeAheadOfLive(newPosition.positionMs)

// Seek back to the optimal live spot
if (timeAheadOfLive > 100) {
player.seekTo(newPosition.positionMs - timeAheadOfLive)
}
}
}

synchronized(liveManagers) {
liveManagers[player] = liveManager
}
synchronized(listeners) {
player.addListener(listener)
listeners[player] = listener
}
}

fun unregisterPlayer(player: Player?) {
if (player == null) {
debugWarning { "LiveHelper unregisterPlayer called with null player!" }
return
}
// Prevent duplicates
if (!liveManagers.contains(player) && !listeners.contains(player)) {
return
}

synchronized(liveManagers) {
liveManagers.remove(player)
}
synchronized(listeners) {
listeners[player]?.let {
player.removeListener(it)
}
listeners.remove(player)
}
}

fun getLiveManager(player: Player?) = liveManagers[player]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.lagradost.cloudstream3.ui.player.live

import androidx.media3.common.C
import androidx.media3.common.Player
import java.lang.ref.WeakReference

// How much margin from the live point is still considered "live"
const val LIVE_MARGIN = 6_000L

// How many ms should we be behind the real live point?
// Too low, and we cannot pre-buffer
// Too high, and we are no longer live
const val PREFERRED_LIVE_OFFSET = 5_000L

// An extra offset from the optimal calculated timestamp
// This is to account for chunk updates not always being the same size
const val CHUNK_VARIANCE = 3000L

// A livestream chunk from the player, the time we get it and the duration can be used to calculate
// the expected live timestamp.
class LivestreamChunk(
durationMs: Long, val receiveTimeMs: Long = System.currentTimeMillis()
) {
// We want to be PREFERRED_LIVE_OFFSET ms after the latest update, but we cannot be ahead of the middle point.
// If we are ahead of the middle point we will reach the end before the new chunk is expected to be released.
val targetPosition = maxOf(0,minOf(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be calculated using an average of several chunks. It would increase accuracy by a bit but increase complexity.

durationMs - PREFERRED_LIVE_OFFSET,
durationMs / 2 - CHUNK_VARIANCE
))

fun isPositionLive(position: Long): Boolean {
val currentTime = System.currentTimeMillis()
val livePosition = targetPosition + (currentTime - receiveTimeMs)
val withinLive = position + LIVE_MARGIN > livePosition - PREFERRED_LIVE_OFFSET
// println("Position: $position, livePosition: ${livePosition}, Behind live: ${livePosition-position}, within live: $withinLive")
return withinLive
}

fun getTimeAheadOfLive(position: Long): Long {
val currentTime = System.currentTimeMillis()
val livePosition = targetPosition + (currentTime - receiveTimeMs)
// println("Ahead of live: ${position-livePosition}")
return position - livePosition
}
}

// There are two types of livestreams we need to manage
// 1. A livestream with no history, a continually sliding window.
// This livestream has no currentLiveOffset, which means we need to calculate
// the real live point based on when we receive the latest update and the size of that update.
// 2. A livestream with history.
// This livestream has a currentLiveOffset and therefore requires no calculation to get the live point.
// currentLiveOffset can however be inaccurate, and we need to be able to fall back to manual calculations.
class LiveManager {
private var _currentPlayer: WeakReference<Player>? = null
val currentPlayer: Player? get() = _currentPlayer?.get()

constructor(player: Player?) {
_currentPlayer = WeakReference(player)
}

private var lastLivestreamChunk: LivestreamChunk? = null

fun submitLivestreamChunk(chunk: LivestreamChunk) {
lastLivestreamChunk = chunk
}

/** Returns how much a position is ahead of the calculated live window. Returns 0 if not ahead of live window */
fun getTimeAheadOfLive(position: Long): Long {
val player = currentPlayer ?: return 0
if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return 0

// If the currentLiveOffset is wrong we fall back to manual calculations
val ahead = if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) {
val relativeOffset = player.currentLiveOffset - player.currentPosition + position
PREFERRED_LIVE_OFFSET - relativeOffset
} else {
lastLivestreamChunk?.getTimeAheadOfLive(position) ?: 0
}

// Ensure min of 0
return maxOf(0, ahead)
}

/** Check if the stream is currently at the expected live edge, with margins */
fun isAtLiveEdge(): Boolean {
val player = currentPlayer ?: return false
if (!player.isCurrentMediaItemDynamic || player.duration == C.TIME_UNSET) return false

// If the currentLiveOffset is wrong we fall back to manual calculations
return if (player.currentLiveOffset != C.TIME_UNSET && player.currentLiveOffset < player.duration) {
player.currentLiveOffset < LIVE_MARGIN + PREFERRED_LIVE_OFFSET
} else {
lastLivestreamChunk?.isPositionLive(player.currentPosition) == true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.lagradost.cloudstream3.ui.player.live

import android.content.Context
import android.util.AttributeSet
import androidx.annotation.OptIn
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.PlayerControlView
import androidx.media3.ui.PlayerView
import androidx.media3.ui.R
import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar
import java.lang.ref.WeakReference


@OptIn(UnstableApi::class)
class LivePreviewTimeBar(val ctx: Context, attrs: AttributeSet) : PreviewTimeBar(ctx, attrs) {

private var _currentPlayerView: WeakReference<PlayerView>? = null
val currentPlayer: Player? get() = _currentPlayerView?.get()?.player

fun registerPlayerView(player: PlayerView?) {
_currentPlayerView = WeakReference(player)
val controller =
_currentPlayerView?.get()?.findViewById<PlayerControlView>(R.id.exo_controller)

controller?.setProgressUpdateListener { position, bufferedPosition ->
currentPlayer?.let { player ->
if (isAtLiveEdge()) {
setPosition(player.duration)
}
}
}
}

fun isAtLiveEdge(): Boolean {
return LiveHelper.getLiveManager(currentPlayer)?.isAtLiveEdge() == true
}
}
Loading