diff --git a/app/build.gradle b/app/build.gradle index 861a5ef..e341799 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -47,6 +47,8 @@ dependencies { implementation "androidx.media3:media3-exoplayer:$mediaVersion" implementation "androidx.media3:media3-ui:$mediaVersion" implementation "androidx.media3:media3-exoplayer-dash:$mediaVersion" + implementation "androidx.media3:media3-datasource:$mediaVersion" + implementation "androidx.media3:media3-exoplayer-hls:$mediaVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.2" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 68deebc..b69ccc2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ package="com.uptech.videolist"> + + create(modelClass: Class): T = MainViewModel( - PlayersPool( + NoOpPlayersPool( applicationContext, //use predefined number of codecs if there are not enough hardware codecs available on //device. P.S. as tests show app doesn't use more than 4 codecs instances - minOf(4, availableCodecsNum()) + //4minOf(4, availableCodecsNum()) ) ) as T } @@ -40,18 +51,42 @@ class MainActivity : AppCompatActivity() { ) } + lateinit var binding: ActivityMainBinding + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val binding = ActivityMainBinding.inflate(layoutInflater) + binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.videoList.adapter = adapter + val videoCacheDir = File(applicationContext.externalCacheDir, VIDEO_CACHE_DIR) + val videoCache = SimpleCache( + videoCacheDir, + NoOpCacheEvictor(), + StandaloneDatabaseProvider(this) + ) + val cacheSink = CacheDataSink.Factory() + .setCache(videoCache) + val upstreamFactory = DefaultDataSource.Factory( + this, + DefaultHttpDataSource.Factory() + ) + val downStreamFactory = FileDataSource.Factory() + val cacheDataSourceFactory = CacheDataSource.Factory() + .setCache(videoCache) + .setCacheWriteDataSinkFactory(cacheSink) + .setCacheReadDataSourceFactory(downStreamFactory) + .setUpstreamDataSourceFactory(upstreamFactory) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) lifecycleScope.launch { viewModel.playbackPositions .onEach { playbackPositions -> adapter.playbackPositions = playbackPositions } .launchIn(this) viewModel.videoUrls - .onEach(adapter::updateVideoUrls) - .launchIn(this) + .onEach { videoUrls -> + ProgressiveMediaSource.Factory(cacheDataSourceFactory).run { + videoUrls.map { url -> createMediaSource(MediaItem.fromUri(url)) } + }.let { mediaSources -> adapter.updateVideoUrls(mediaSources) } + }.launchIn(this) } } @@ -78,4 +113,8 @@ class MainActivity : AppCompatActivity() { viewModel.releasePlayers() super.onStop() } + + companion object { + const val VIDEO_CACHE_DIR = "videoCache" + } } \ No newline at end of file diff --git a/app/src/main/java/com/uptech/videolist/NoOpPlayersPool.kt b/app/src/main/java/com/uptech/videolist/NoOpPlayersPool.kt new file mode 100644 index 0000000..0cf6ea0 --- /dev/null +++ b/app/src/main/java/com/uptech/videolist/NoOpPlayersPool.kt @@ -0,0 +1,46 @@ +package com.uptech.videolist + +import android.content.Context +import androidx.media3.common.Player +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.channels.Channel + +class NoOpPlayersPool( + private val context: Context +) : PlayersPool { + private val playerMap: MutableMap = mutableMapOf() + private var playerIndex: Int = 0 + + override fun acquire(url: String): Channel = + playerMap[url]?.let { reusablePlayer -> + Channel(capacity = 1).apply { + trySend(reusablePlayer) + } + } ?: Channel(capacity = 1).apply { + trySend( + ExoPlayer.Builder(context) + .setLoadControl( + DefaultLoadControl.Builder() + .setBufferDurationsMs( + 10_000, + 10_000, + 100, + 2000 + ).build() + ) + .build() + .let { exoPlayer -> ReusablePlayer(exoPlayer) } + .apply { playerId = "player${playerIndex++}" } + .also { reusablePlayer -> playerMap[url] = reusablePlayer } + ) + } + + override fun removeFromAwaitingQueue(channel: Channel) {} + + override fun release(player: Player) {} + + override fun stop(player: Player) {} + + override fun releaseAll() {} +} \ No newline at end of file diff --git a/app/src/main/java/com/uptech/videolist/PlayersPool.kt b/app/src/main/java/com/uptech/videolist/PlayersPool.kt index 03e7d08..17623b9 100644 --- a/app/src/main/java/com/uptech/videolist/PlayersPool.kt +++ b/app/src/main/java/com/uptech/videolist/PlayersPool.kt @@ -1,68 +1,13 @@ package com.uptech.videolist -import android.content.Context import androidx.media3.common.Player -import androidx.media3.exoplayer.ExoPlayer import kotlinx.coroutines.channels.Channel -import timber.log.Timber -import java.util.LinkedList -import java.util.Queue -class PlayersPool( - private val context: Context, - private val maxPoolSize: Int -) { - private val unlockedPlayers: MutableList = mutableListOf(ExoPlayer.Builder(context).build()) - private val lockedPlayers: MutableList = mutableListOf() +interface PlayersPool { - private val waitingQueue: Queue> = LinkedList() - - @Synchronized - fun acquire(): Channel = - if(unlockedPlayers.isEmpty()) { - if(lockedPlayers.size >= maxPoolSize) { - Channel(capacity = 1).also { channel -> waitingQueue.offer(channel) } - } else { - Channel(capacity = 1).apply { - trySend(ExoPlayer.Builder(context).build().also(lockedPlayers::add)) - } - } - } else { - Channel(capacity = 1).apply { - trySend(unlockedPlayers.removeLast().also(lockedPlayers::add)) - } - }.also { - Timber.tag(VIDEO_LIST).d("pool size = %s", lockedPlayers.size + unlockedPlayers.size) - } - - @Synchronized - fun removeFromAwaitingQueue(channel: Channel) { - waitingQueue.remove(channel) - } - - @Synchronized - fun release(player: Player) { - lockedPlayers.remove(player) - } - - @Synchronized - fun stop(player: Player) { - if(!reusePlayer(player)) { - lockedPlayers.remove(player) - unlockedPlayers.add(player) - } - } - - private fun reusePlayer(player: Player): Boolean = - waitingQueue.poll()?.run { - trySend(player) - true - } ?: false - - @Synchronized - fun releaseAll() { - waitingQueue.clear() - unlockedPlayers.addAll(lockedPlayers) - lockedPlayers.clear() - } + fun acquire(url: String = ""): Channel + fun removeFromAwaitingQueue(channel: Channel) + fun release(player: Player) + fun stop(player: Player) + fun releaseAll() } \ No newline at end of file diff --git a/app/src/main/java/com/uptech/videolist/ReusablePlayer.kt b/app/src/main/java/com/uptech/videolist/ReusablePlayer.kt new file mode 100644 index 0000000..69d7003 --- /dev/null +++ b/app/src/main/java/com/uptech/videolist/ReusablePlayer.kt @@ -0,0 +1,82 @@ +package com.uptech.videolist + +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.MediaSource +import timber.log.Timber + +class ReusablePlayer( + val player: ExoPlayer +) { + var playerId: String = "" + private var playbackHistory: MutableList = mutableListOf() + private var prepareTime: Long = 0L + + init { + player.addListener( + object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + ExoPlayer.STATE_IDLE -> { + Timber.tag(VIDEO_TAG).d( + "Idle player number: %s", + playerId + ) + } + ExoPlayer.STATE_BUFFERING -> { + Timber.tag(VIDEO_TAG).d( + "Buffering player number: %s", + playerId + ) + } + ExoPlayer.STATE_READY -> { + Timber.tag(VIDEO_TAG).d( + "Ready player number: %s, preparation %d ms", + playerId, + System.currentTimeMillis() - prepareTime + ) + } + ExoPlayer.STATE_ENDED -> + Timber.tag(VIDEO_TAG).d( + "Ended player number: %s", + playerId + ) + else -> {} + } + } + } + ) + } + + fun setMediaSource(mediaSource: MediaSource) { + val mediaId: String = mediaSource.mediaItem.playbackProperties?.uri.toString() + .substringAfterLast('/') + if (playbackHistory.size > 0) { + if (mediaId != playbackHistory.last()) { + Timber.tag(VIDEO_TAG).d( + "Player %s rebind from %s to %s media source", + playerId, + playbackHistory.last(), + mediaId + ) + } + } else { + Timber.tag(VIDEO_TAG).d( + "Player %s first bind to %s media source", + playerId, + mediaId + ) + } + playbackHistory += mediaId + player.setMediaSource(mediaSource) + } + + fun prepare() { + prepareTime = System.currentTimeMillis() + player.prepare() + } + + companion object { + const val VIDEO_TAG = "videoTag" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/uptech/videolist/ReusablePlayersPool.kt b/app/src/main/java/com/uptech/videolist/ReusablePlayersPool.kt new file mode 100644 index 0000000..b79f585 --- /dev/null +++ b/app/src/main/java/com/uptech/videolist/ReusablePlayersPool.kt @@ -0,0 +1,86 @@ +package com.uptech.videolist + +import android.content.Context +import androidx.media3.common.Player +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.channels.Channel +import timber.log.Timber +import java.util.LinkedList +import java.util.Queue + +class ReusablePlayersPool( + private val context: Context, + private val maxPoolSize: Int +) : PlayersPool { + private val unlockedPlayers: MutableList = mutableListOf() + private val lockedPlayers: MutableList = mutableListOf() + private var playerIndex: Int = 0 + + private val waitingQueue: Queue> = LinkedList() + + @Synchronized + override fun acquire(url: String): Channel = + if(unlockedPlayers.isEmpty()) { + if(lockedPlayers.size >= maxPoolSize) { + Channel(capacity = 1).also { channel -> waitingQueue.offer(channel) } + } else { + Channel(capacity = 1).apply { + trySend( + ExoPlayer.Builder(context) + .setLoadControl( + DefaultLoadControl.Builder() + .setBufferDurationsMs( + 10_000, + 10_000, + 100, + 2000 + ).build() + ) + .build() + .let { exoPlayer -> ReusablePlayer(exoPlayer) } + .apply { playerId = "player${playerIndex++}" } + .also { player -> lockedPlayers.add(player) } + ) + } + } + } else { + Channel(capacity = 1).apply { + trySend(unlockedPlayers.removeLast().also(lockedPlayers::add)) + } + }.also { + Timber.tag(VIDEO_LIST).d("pool size = %s", lockedPlayers.size + unlockedPlayers.size) + } + + @Synchronized + override fun removeFromAwaitingQueue(channel: Channel) { + waitingQueue.remove(channel) + } + + @Synchronized + override fun release(player: Player) { + lockedPlayers.removeAll { reusablePlayer -> reusablePlayer.player == player } + } + + @Synchronized + override fun stop(player: Player) { + val reusablePlayer = lockedPlayers.first { reusablePlayer -> reusablePlayer.player == player } + if (!reusePlayer(reusablePlayer)) { + lockedPlayers.remove(reusablePlayer) + unlockedPlayers.add(reusablePlayer) + } + } + + private fun reusePlayer(player: ReusablePlayer): Boolean = + waitingQueue.poll()?.run { + trySend(player) + true + } ?: false + + @Synchronized + override fun releaseAll() { + waitingQueue.clear() + unlockedPlayers.addAll(lockedPlayers) + lockedPlayers.clear() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/uptech/videolist/VideoAdapter.kt b/app/src/main/java/com/uptech/videolist/VideoAdapter.kt index 177d5f8..365be36 100644 --- a/app/src/main/java/com/uptech/videolist/VideoAdapter.kt +++ b/app/src/main/java/com/uptech/videolist/VideoAdapter.kt @@ -2,8 +2,7 @@ package com.uptech.videolist import android.view.LayoutInflater import android.view.ViewGroup -import androidx.media3.common.MediaItem -import androidx.media3.common.Player +import androidx.media3.exoplayer.source.MediaSource import androidx.recyclerview.widget.RecyclerView import com.uptech.videolist.MainViewModel.PlayersAction import com.uptech.videolist.MainViewModel.PlayersAction.RELEASE @@ -20,7 +19,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import timber.log.Timber class VideoAdapter( private val playersPool: PlayersPool, @@ -29,10 +27,10 @@ class VideoAdapter( private val dispatcher: CoroutineDispatcher, private val updatePlaybackPosition: (Int, Long) -> Unit ) : RecyclerView.Adapter() { - private var videoUrls: List = listOf() + private var videoUrls: List = listOf() var playbackPositions: List = listOf() - fun updateVideoUrls(videoUrls: List) { + fun updateVideoUrls(videoUrls: List) { this.videoUrls = videoUrls notifyDataSetChanged() } @@ -62,22 +60,18 @@ class VideoAdapter( private val binding: VideoItemViewBinding ) : RecyclerView.ViewHolder(binding.root) { private lateinit var videoScope: CoroutineScope - private lateinit var playerChannel: Channel + private lateinit var playerChannel: Channel private var playJob: Job? = null private var restartJob: Job? = null - fun bind(url: String, playbackPosition: Long) { + fun bind(mediaSource: MediaSource, playbackPosition: Long) { videoScope = CoroutineScope(Job() + dispatcher) - bindPlayer(url, playbackPosition) + bindPlayer(mediaSource, playbackPosition) if(restartJob === null) { restartJob = playersActions .onEach { action -> when(action) { RELEASE -> with(binding.playerView) { - Timber.tag(VIDEO_LIST).d( - "Release player: url = %s", - url.substringAfterLast('/') - ) updatePlaybackPosition(absoluteAdapterPosition, player?.currentPosition ?: 0) player?.run { release() @@ -106,27 +100,17 @@ class VideoAdapter( } } - private fun bindPlayer(url: String, playbackPosition: Long) { + private fun bindPlayer(mediaSource: MediaSource, playbackPosition: Long) { playJob?.cancel() playJob = videoScope.launch { - Timber.tag(VIDEO_LIST).d( - "Awaiting for player url = %s, playbackPosition = %d", - url.substringAfterLast('/'), - playbackPosition - ) - playersPool.acquire() + playersPool.acquire(mediaSource.mediaItem.playbackProperties?.uri.toString()) .also { playerChannel = it } .receive() .run { - Timber.tag(VIDEO_LIST).d( - "Playing url = %s, playbackPosition = %d", - url.substringAfterLast('/'), - playbackPosition - ) - binding.playerView.player = this - setMediaItem(MediaItem.fromUri(url)) - playWhenReady = true - seekTo(0, playbackPosition) + binding.playerView.player = player + setMediaSource(mediaSource) + player.playWhenReady = true + player.seekTo(0, playbackPosition) prepare() } } @@ -141,11 +125,6 @@ class VideoAdapter( updatePlaybackPosition(absoluteAdapterPosition, currentPosition) playersPool.stop(this) } - Timber.tag(VIDEO_LIST).d( - "Player detached: url = %s, playback position = %d", - videoUrls[absoluteAdapterPosition].substringAfterLast('/'), - player?.currentPosition - ) videoScope.cancel() player = null } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index df56b14..3c84c31 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -6,6 +6,7 @@ android:layout_height="match_parent" tools:context=".MainActivity" > + -