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"
>
+
-