/* * Copyright (c) Double Symmetry GmbH * Commercial use requires a license. See https://rntp.dev/pricing */ package com.doublesymmetry.trackplayer import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Timeline import com.doublesymmetry.trackplayer.models.LiveResumeBehavior import java.util.IdentityHashMap import java.util.concurrent.atomic.AtomicLong enum class PlaybackRoute { LOCAL, REMOTE_CAST, } object LiveEdgeController { private data class RefreshState( val generation: Long, val currentIndex: Int, val items: List, val markedItem: MediaItem, val listener: Player.Listener, ) private val refreshStates = IdentityHashMap() private val nextGeneration = AtomicLong() fun isLive(player: Player): Boolean = player.currentMediaItem?.mediaMetadata?.extras ?.getBoolean("isLive", false) == true fun seekToLiveEdge(player: Player, route: PlaybackRoute): Boolean { if (!isLive(player)) return false val playWhenReady = player.playWhenReady if (route == PlaybackRoute.REMOTE_CAST || player.isCurrentMediaItemSeekable) { if (!player.isCommandAvailable(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)) return false player.seekToDefaultPosition() if ( route == PlaybackRoute.LOCAL && player.playbackState == Player.STATE_IDLE && player.isCommandAvailable(Player.COMMAND_PREPARE) ) { player.prepare() } } else { if (!refreshLocalSource(player)) return false } player.playWhenReady = playWhenReady return true } private fun refreshLocalSource(player: Player): Boolean { if ( !player.isCommandAvailable(Player.COMMAND_CHANGE_MEDIA_ITEMS) || !player.isCommandAvailable(Player.COMMAND_PREPARE) || !player.isCommandAvailable(Player.COMMAND_SEEK_TO_DEFAULT_POSITION) || player.mediaItemCount == 0 ) return false synchronized(refreshStates) { if (refreshStates.containsKey(player)) return true val generation = nextGeneration.incrementAndGet() val items = List(player.mediaItemCount) { player.getMediaItemAt(it) } val currentIndex = player.currentMediaItemIndex.coerceIn(items.indices) val refreshedItem = items[currentIndex].buildUpon().build() val listener = object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { Player.STATE_IDLE, Player.STATE_ENDED -> finishRefresh(player, generation) Player.STATE_READY -> finishRefresh(player, generation) } } override fun onPlayerError(error: PlaybackException) { finishRefresh(player, generation) } override fun onTimelineChanged(timeline: Timeline, reason: Int) { if (!snapshotMatches(player, generation)) finishRefresh(player, generation) } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { if (!snapshotMatches(player, generation)) finishRefresh(player, generation) } } refreshStates[player] = RefreshState(generation, currentIndex, items, refreshedItem, listener) player.addListener(listener) LiveRefreshTokens.mark(refreshedItem, generation) try { player.replaceMediaItem(currentIndex, refreshedItem) } catch (error: RuntimeException) { LiveRefreshTokens.discard(refreshedItem) finishRefresh(player, generation) throw error } } player.seekToDefaultPosition() player.prepare() return true } private fun snapshotMatches(player: Player, generation: Long): Boolean = synchronized(refreshStates) { val state = refreshStates[player] ?: return@synchronized false state.generation == generation && player.currentMediaItemIndex == state.currentIndex && player.mediaItemCount == state.items.size && state.items.indices.all { player.getMediaItemAt(it) == state.items[it] } } private fun finishRefresh(player: Player, generation: Long) { synchronized(refreshStates) { val state = refreshStates[player] if (state?.generation == generation) { refreshStates.remove(player) LiveRefreshTokens.discard(state.markedItem) player.removeListener(state.listener) } } } fun play(player: Player, behavior: LiveResumeBehavior, route: PlaybackRoute) { if (behavior == LiveResumeBehavior.LIVE_EDGE) { seekToLiveEdge(player, route) } player.play() } }