How to Use ExoPlayer Media3 for Video Caching in a RecyclerView

764 views Asked by At

I am working on an Android project where I want to display videos in a RecyclerView using ExoPlayer Media3. To achieve video caching and smooth playback, I've implemented a CacheManager class, an ExoPlayerManager class, and a custom ViewHolder class. However, I'm facing some challenges and would appreciate guidance on how to use ExoPlayer effectively for this purpose.

Here's an overview of my code:

CacheManager Class:

object CacheManager {
private lateinit var cache: SimpleCache
fun initialize(context: Context) {
    if (!::cache.isInitialized) {
        val cacheDirectory = File(context.cacheDir, "ExoplayerCache")
        val maxCacheSize = 100 * 1024 * 1024 // 100 MB cache size
        val evictor = LeastRecentlyUsedCacheEvictor(maxCacheSize.toLong())
        val databaseProvider: DatabaseProvider = StandaloneDatabaseProvider(context)
        cache = SimpleCache(cacheDirectory, evictor, databaseProvider)
    }
}

fun getCache(): SimpleCache {
    return cache
}

}

ExoPlayerManager Class:

object ExoPlayerManager {
private val players: MutableList<ExoPlayer> = mutableListOf()

fun initializePlayer(context: Context): ExoPlayer {
    CacheManager.initialize(context)
    val player = players.firstOrNull { it.playbackState == Player.STATE_IDLE }
        ?: createNewPlayer(context)
    players.remove(player)
    return player
}

private fun createNewPlayer(context: Context): ExoPlayer {
    val player = ExoPlayer.Builder(context).build()
    player.setHandleAudioBecomingNoisy(true)
    return player
}

fun setMediaItem(
    exoPlayer: ExoPlayer,
    videoUri: String,
    playbackPosition: Long = 0L,
    playbackStateListener: Player.Listener
) {
    val cacheDataSourceFactory: DataSource.Factory =
        CacheDataSource.Factory()
            .setCache(CacheManager.getCache())
            .setUpstreamDataSourceFactory(
                DefaultHttpDataSource.Factory()
                .setUserAgent("ExoPlayer"))

    val mediaSource = ProgressiveMediaSource.Factory(cacheDataSourceFactory)
        .createMediaSource(MediaItem.fromUri(videoUri))

    exoPlayer.setMediaSource(mediaSource)
    exoPlayer.repeatMode = Player.REPEAT_MODE_ONE
    exoPlayer.playWhenReady = true
    exoPlayer.addListener(playbackStateListener)
    exoPlayer.prepare()
    exoPlayer.play()
}

fun releasePlayer(holder: HomeTopBannerViewHolder, listItems: BaseModel) {
    val player = holder.exoPlayer
    listItems.playbackPosition = player?.currentPosition ?: 0L
    player?.removeListener(holder.playbackStateListener())
    player?.stop()
    player?.clearMediaItems()
    players.add(player)
}

fun releaseAllPlayers() {
    for (player in players) {
        player.release()
    }
    players.clear()
}

}

MyViewHolder Class:

class MyViewHolder(
var binding: LayoutHomeTopBannerBinding, val clickListener: ListActionClickListener?

) : BaseViewHolder(binding.root) {

var exoPlayer: ExoPlayer? = null

fun playbackStateListener(
    ivLoader: LottieAnimationView? = null
) = object : Player.Listener{
    override fun onPlaybackStateChanged(playbackState: Int) {
        when (playbackState) {
            Player.STATE_ENDED -> {
                // Handle playback ended
            }

            Player.STATE_READY -> {
                ivLoader?.visibility = View.GONE
            }

            Player.STATE_BUFFERING -> {
                ivLoader?.visibility = View.VISIBLE
            }
        }
    }
}

override fun onBind(item: BaseModel, position: Int) {
    exoPlayer = ExoPlayerManager.initializePlayer(binding.root.context)


    val screenHeight =
        binding.root.context.findActivity()?.getRealScreenSize()?.second.getSafe() //height
    binding.apply {
        itemView.apply {

           
            videoView.layoutParams.height = screenHeight * 3 / 5
            videoView.requestLayout()
            ivLoader.layoutParams.height = screenHeight * 3 / 5
            ivLoader.requestLayout()

            }


        itemView.setOnClickListener {
            clickListener?.onItemClick(ListActionClickType.OnItemClick(item, position))
        }

        videoView.player = exoPlayer
        ExoPlayerManager.setMediaItem(
            exoPlayer!!,
            "Any Url",
            item.playbackPosition,
            playbackStateListener(ivLoader)
        )

    }


}

}

I have a few questions regarding this setup:

How can I efficiently cache videos using the CacheManager class and ensure that they are reused in the RecyclerView?

Is the way I'm initializing and using ExoPlayer in the ExoPlayerManager class correct, especially in the setMediaItem method?

Are there any potential issues or improvements that I should be aware of when working with ExoPlayer in a RecyclerView context?

Any guidance, code samples, or suggestions on how to optimize this implementation would be greatly appreciated. Thank you!

1

There are 1 answers

0
Walid Ahmed On

In addressing challenges related to RecyclerView video playback using ExoPlayer Media3, I have to enhance my ExoplayerManager class like this:

@UnstableApi
object ExoPlayerManager {
    private val players: MutableList<ExoPlayer> = mutableListOf()
    private val playersTemp: MutableList<ExoplayerModelClass> = mutableListOf()
    private var downloadCache: Cache? = null
    private val DOWNLOAD_CONTENT_DIRECTORY = "downloads"

  
    fun initializePlayer(context: Context, position: Int): ExoPlayer {
        val player = ExoPlayer.Builder(context).build()
        player.setHandleAudioBecomingNoisy(true)
        players.add(player) // Add player to the list when initialized
        playersTemp.add(ExoplayerModelClass(player, position)) // Add player model to temp list
        return player
    }

    private fun createNewPlayer(context: Context): ExoPlayer {
        val player = ExoPlayer.Builder(context).build()
        player.setHandleAudioBecomingNoisy(true)
        return player
    }

    fun setMediaItem(
        exoPlayer: ExoPlayer,
        videoUri: String,
        playbackPosition: Long = 0L,
        playbackStateListener: Player.Listener
    ) {

        val mediaItem = MediaItem.fromUri(videoUri)
        val mediaSource =
            ProgressiveMediaSource.Factory(buildCacheDataSourceFactory())
                .createMediaSource(mediaItem)
        exoPlayer.setMediaSource(mediaSource, playbackPosition)
        exoPlayer.repeatMode = Player.REPEAT_MODE_ONE
        exoPlayer.playWhenReady = true
        exoPlayer.addListener(playbackStateListener)
        exoPlayer.prepare()
      
    }


    fun releasePlayer(
        holder: LayoutBannerImageWithDescViewHolder,
        listItems: BaseModel
    ) {
        val player = holder.exoPlayer
        listItems.playbackPosition = player?.currentPosition ?: 0L
        player?.removeListener(holder.playbackStateListener())
        player?.stop()
        player?.clearMediaItems()
        if (player != null) {
            players.remove(player)
            playersTemp.remove(ExoplayerModelClass(player, holder.bindingAdapterPosition))
        }
    }

    fun releasePlayer(
        holder: HeaderTextButtonInsideBannerImageViewHolder,
        listItems: BaseModel
    ) {
        val player = holder.exoPlayer
        listItems.playbackPosition = player?.currentPosition ?: 0L
        player?.removeListener(holder.playbackStateListener())
        player?.stop()
        player?.clearMediaItems()
        if (player != null) {
            players.remove(player)
            playersTemp.remove(ExoplayerModelClass(player, holder.bindingAdapterPosition))
        }
    }

    fun releasePlayer(
        holder: HomeTopBannerViewHolder,
        listItems: BaseModel
    ) {
        val player = holder.exoPlayer
        listItems.playbackPosition = player?.currentPosition ?: 0L
        player?.removeListener(holder.playbackStateListener())
        player?.stop()
        player?.clearMediaItems()
        if (player != null) {
            players.remove(player)
            playersTemp.remove(ExoplayerModelClass(player, holder.bindingAdapterPosition))
        }
    }


    fun releaseAllPlayers() {
        for (player in players) {
            player.stop()
            player.release()
        }
        players.clear()
        playersTemp.clear()

    }

    fun getRunningPlayers():ArrayList<ExoPlayer>{
        return arrayListOf<ExoPlayer>().also {
            players.forEach {player ->
                if(player.isPlaying || player.playWhenReady){
                    it.add(player)
                }
            }
        }
    }

    fun pauseAllPlayers() {
        for (player in playersTemp) {
            player.exoplayer.playWhenReady = false
            player.exoplayer.pause()
        }
    }

    fun pauseAllPlayer(exoPlayer: ExoPlayer?) {
        for (player in playersTemp) {
            if (exoPlayer == player.exoplayer) {

            } else {
                player.exoplayer.playWhenReady = false
                player.exoplayer.pause()
            }
        }
    }

    fun playExoPlayer(exoPlayer: ExoPlayer?) {
        for (player in playersTemp) {
            if (exoPlayer == player.exoplayer) {
                exoPlayer?.playWhenReady = true
            } else {
                player.exoplayer.playWhenReady = false
                player.exoplayer.pause()
            }
        }
    }

    fun playNextOrPreviousVideo(
        exoPlayer: ExoPlayer?,
        bindingAdapterPosition: Int,
        scrollUp: Boolean
    ) {
        if (playersTemp.size > 1) {
            playersTemp.sortBy { it.position }
            val index = playersTemp.indexOf(exoPlayer?.let {
                ExoplayerModelClass(
                    it,
                    bindingAdapterPosition
                )
            })
            if (scrollUp) {
                if (index != 0) {
                    playersTemp[index - 1].exoplayer.playWhenReady = true
                    playersTemp[index - 1].exoplayer.play()
                }
            } else {
                if (index != playersTemp.lastIndex) {
                    playersTemp[index + 1].exoplayer.playWhenReady = true
                    playersTemp[index + 1].exoplayer.play()
                }
            }
        }
    }

    fun buildCacheDataSourceFactory(): DataSource.Factory {
        val cache = getDownloadCache()
        val cacheSink = CacheDataSink.Factory()
            .setCache(cache)
        val upstreamFactory =
            DefaultDataSource.Factory(getAppContext(), DefaultHttpDataSource.Factory())
        return CacheDataSource.Factory()
            .setCache(cache)
            .setCacheWriteDataSinkFactory(cacheSink)
            .setCacheReadDataSourceFactory(FileDataSource.Factory())
            .setUpstreamDataSourceFactory(upstreamFactory)
            .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
    }

    @Synchronized
    private fun getDownloadCache(): Cache {
        if (downloadCache == null) {
            val downloadContentDirectory = File(
                getAppContext().cacheDir,
                DOWNLOAD_CONTENT_DIRECTORY
            )
            downloadCache =
                SimpleCache(
                    downloadContentDirectory, NoOpCacheEvictor(), StandaloneDatabaseProvider(
                        getAppContext()
                    )
                )
        }
        return downloadCache!!
    }
}

Few methods are specific to my needs and the methods that work for me are:

buildCacheDataSourceFactory(), getDownloadCache()

Here are my Extension functions of Recyclerview that help me to reuse the same code for playing and resuming the video on scrolling

@UnstableApi
fun RecyclerView.addCustomScrollListener(
    onFirstItemVisible: () -> Unit,
    onFirstItemNotVisible: () -> Unit,
    onSecondItemNotVisible: () -> Unit
) {
    var firstVisibleItem: Int = 0
    var lastVisibleItem: Int = 0
    var visibleCount: Int = 0
    var y: Int = 0
    val layoutManager = layoutManager as? LinearLayoutManager ?: return

    val scrollListener = object : FirstItemScrollListener() {

        override fun onFirstItemVisible() {
            onFirstItemVisible.invoke()
        }

        override fun onFirstItemNotVisible() {
            onFirstItemNotVisible.invoke()
        }

        override fun onSecondItemNotVisible() {
            onSecondItemNotVisible.invoke()
        }

        override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
            super.onScrollStateChanged(recyclerView, newState)
            when (newState) {
                RecyclerView.SCROLL_STATE_IDLE -> {

                    for (i in 0 until visibleCount) {
                        val view =
                            recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
                                ?: continue

                        if (view is HomeTopBannerViewHolder) {
                            if (view.binding.videoView.visibility == View.VISIBLE) {
                                handlePlayAndPauseCondition(view)
                            }
                            break
                        }
                        if (view is LayoutBannerImageWithDescViewHolder) {
                            if (view.binding.videoView.visibility == View.VISIBLE) {
                                handlePlayAndPauseCondition(view)
                            }
                            break
                        }
                        if (view is HeaderTextButtonInsideBannerImageViewHolder) {
                            if (view.binding.videoView.visibility == View.VISIBLE) {
                                handlePlayAndPauseCondition(view)
                            }
                            break
                        }
                    }
                }
            }
        }

        private fun handlePlayAndPauseCondition(view: HomeTopBannerViewHolder) {
            if (y <= 0) {
                //scroll up
                if (visibleAreaOffset(
                        view.binding.videoView,
                        view.itemView
                    ) >= 0.30F
                ) {
                    ExoPlayerManager.pauseAllPlayer(view.exoPlayer)
                    view.startPlayback()
                } else {
                    ExoPlayerManager.playNextOrPreviousVideo(
                        view.exoPlayer,
                        view.bindingAdapterPosition,
                        true
                    )
                    view.pausePlayback()
                }
                return
            } else {

                //scroll down
                y = 0;
                if (visibleAreaOffset(
                        view.binding.videoView,
                        view.itemView
                    ) <= 0.30F
                ) {
                    ExoPlayerManager.playNextOrPreviousVideo(
                        view.exoPlayer,
                        view.bindingAdapterPosition,
                        false
                    )
                    view.pausePlayback()
                } else {
                    ExoPlayerManager.pauseAllPlayer(view.exoPlayer)
                    view.startPlayback()
                }
                return
            }
        }

        private fun handlePlayAndPauseCondition(view: HeaderTextButtonInsideBannerImageViewHolder) {
            if (y <= 0) {
                //scroll up
                if (visibleAreaOffset(
                        view.binding.videoView,
                        view.itemView
                    ) >= 0.30F
                ) {
                    ExoPlayerManager.pauseAllPlayer(view.exoPlayer)
                    view.startPlayback()
                } else {
                    ExoPlayerManager.playNextOrPreviousVideo(
                        view.exoPlayer,
                        view.bindingAdapterPosition,
                        true
                    )
                    view.pausePlayback()
                }
                return
            } else {

                //scroll down
                y = 0;
                if (visibleAreaOffset(
                        view.binding.videoView,
                        view.itemView
                    ) <= 0.30F
                ) {
                    ExoPlayerManager.playNextOrPreviousVideo(
                        view.exoPlayer,
                        view.bindingAdapterPosition,
                        false
                    )
                    view.pausePlayback()
                } else {
                    ExoPlayerManager.pauseAllPlayer(view.exoPlayer)
                    view.startPlayback()
                }
                return
            }
        }

        private fun handlePlayAndPauseCondition(view: LayoutBannerImageWithDescViewHolder) {
            if (y <= 0) {
                //scroll up
                if (visibleAreaOffset(
                        view.binding.videoView,
                        view.itemView
                    ) >= 0.30F
                ) {
                    ExoPlayerManager.pauseAllPlayer(view.exoPlayer)
                    view.startPlayback()
                } else {
                    ExoPlayerManager.playNextOrPreviousVideo(
                        view.exoPlayer,
                        view.bindingAdapterPosition,
                        true
                    )
                    view.pausePlayback()
                }
                return
            } else {

                //scroll down
                y = 0;
                if (visibleAreaOffset(
                        view.binding.videoView,
                        view.itemView
                    ) <= 0.30F
                ) {
                    ExoPlayerManager.playNextOrPreviousVideo(
                        view.exoPlayer,
                        view.bindingAdapterPosition,
                        false
                    )
                    view.pausePlayback()
                } else {
                    ExoPlayerManager.pauseAllPlayer(view.exoPlayer)
                    view.startPlayback()
                }
                return
            }
        }

        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
            val layoutManager = recyclerView.layoutManager as LinearLayoutManager
            firstVisibleItem = layoutManager?.findFirstVisibleItemPosition() ?: 0;
            lastVisibleItem = layoutManager?.findLastVisibleItemPosition() ?: 0;
            visibleCount = (lastVisibleItem - firstVisibleItem) + 1;

            if (dx == 0 && dy == 0 && recyclerView.childCount > 0) {
                var viewHolder = recyclerView.getChildViewHolder(recyclerView.getChildAt(0))
                if (viewHolder is HomeTopBannerViewHolder) {
                    if (viewHolder.binding.videoView.visibility == View.VISIBLE) {
                        viewHolder.startPlayback()
                    }
                }
                if (viewHolder is LayoutBannerImageWithDescViewHolder) {
                    if (viewHolder.binding.videoView.visibility == View.VISIBLE) {
                        viewHolder.startPlayback()
                    }
                }
                if (viewHolder is HeaderTextButtonInsideBannerImageViewHolder) {
                    if (viewHolder.binding.videoView.visibility == View.VISIBLE) {
                        viewHolder.startPlayback()
                    }
                }
            }
            y = dy
        }
    }

    addOnScrollListener(scrollListener)
}

fun getViewRect(view: View): Rect {
    val rect = Rect()
    val offset = Point()
    view.getGlobalVisibleRect(rect, offset)
    return rect
}

fun visibleAreaOffset(player: PlayerView, parent: View): Float {
    val videoRect = getViewRect(player)
    val parentRect = getViewRect(parent)

    return if ((parentRect.contains(videoRect) || parentRect.intersect(videoRect))) {
        val visibleArea = (videoRect.height() * videoRect.width()).toFloat()
        val viewArea = player.width * player.height
        if (viewArea <= 0f) 1f else visibleArea / viewArea
    } else {
        0f
    }
}

Hopefully this answer will be helpful for you to use cache videos in recyclerview, Thanks