diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt index a134ae91137..13309d2c3d0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt @@ -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 @@ -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 @@ -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 @@ -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 @@ -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() } @@ -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 -> @@ -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 @@ -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( @@ -1399,6 +1424,8 @@ class CS3IPlayer : IPlayer { return } + LiveHelper.registerPlayer(exoPlayer) + exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { safe { @@ -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)) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt index fad4a53e1bb..9844648a36a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt @@ -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 = @@ -738,6 +738,7 @@ open class FullScreenPlayer : AbstractPlayerFragment() { activity?.window?.attributes = lp activity?.showSystemUI() } + private fun resetZoomToDefault() { if (zoomMatrix != null) resize(PlayerResize.Fit, false) } @@ -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) { @@ -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 } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt new file mode 100644 index 00000000000..2aa63b0c204 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveHelper.kt @@ -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() + private val listeners = WeakHashMap() + + @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] +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt new file mode 100644 index 00000000000..8d848d46aa9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LiveManager.kt @@ -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( + 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? = 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt new file mode 100644 index 00000000000..3001281fd45 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/live/LivePreviewTimeBar.kt @@ -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? = null + val currentPlayer: Player? get() = _currentPlayerView?.get()?.player + + fun registerPlayerView(player: PlayerView?) { + _currentPlayerView = WeakReference(player) + val controller = + _currentPlayerView?.get()?.findViewById(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 + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/player_custom_layout.xml b/app/src/main/res/layout/player_custom_layout.xml index 72024a918d5..407de4a3f1b 100644 --- a/app/src/main/res/layout/player_custom_layout.xml +++ b/app/src/main/res/layout/player_custom_layout.xml @@ -759,7 +759,7 @@ android:scaleType="centerCrop" /> - + + - + + + - + %d download queued %d downloads queued + Live +