Search code examples
android-custom-viewexoplayerandroid-seekbarvideo-player

How to create a video seek bar with thumbnails like in kinemaster ? In Android?


kinemaster

I am trying to achieve the above with exoplayer.

Creating a thumbnail list from the video at a certain interval . Say 10 seconds And displaying it to the seekbar along with the time .

How to accomplish this ? What are the things to consider when we are dealing with large files ? Is it better creating all thumbnails at first , or generating thumbnails as we seek through the video ?

enter image description here

How do we associate time and corresponding thumbnail like in the above image . Here these images should show between 4s-8s How do we do that ? I don't know how to achieve that using a regular recyclerview. How can we do that with a custom view ?

That's a lot of questions, any helps will be appreciated . Than u


Solution

  • here is the custom view from video timmer library with some modification and use of coroutine, also code contains useful comments

        // This file from video trimmer library with modifications
        // https://github.com/titansgroup/k4l-video-trimmer/blob/develop/k4l-video-trimmer/src/main/java/life/knowledge4/videotrimmer/view/TimeLineView.java
        class TimeLineView @JvmOverloads constructor(
            context: Context,
            attrs: AttributeSet?,
            defStyleAttr: Int = 0
        ) : View(context, attrs, defStyleAttr) {
            private var mVideoUri: Uri? = null
            private var mHeightView = 0
            private var mBitmapList: LongSparseArray<Bitmap?>? = null
            private var onListReady: (LongSparseArray<Bitmap?>) -> Unit = {}
        
            private fun init() {
                mHeightView = context.resources.getDimensionPixelOffset(R.dimen.frames_video_height)
            }
        
            val handler = CoroutineExceptionHandler { _, exception ->
                Timber.e("From CoroutineExceptionHandler", exception.message.toString())
            }
        
            override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
                val minW = paddingLeft + paddingRight + suggestedMinimumWidth
                val w = resolveSizeAndState(minW, widthMeasureSpec, 1)
                val minH = paddingBottom + paddingTop + mHeightView
                val h = resolveSizeAndState(minH, heightMeasureSpec, 1)
                setMeasuredDimension(w, h)
            }
        
            override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
                super.onSizeChanged(w, h, oldW, oldH)
                if (w != oldW) {
                    getBitmap(w)
                }
            }
        
            var job: Job? = null
            private fun getBitmap(viewWidth: Int) {
                if (mBitmapList != null) { // if already got the thumbnails then don't do it again.
                    return
                }
                job?.cancel()
                job = viewScope.launch(Dispatchers.IO + handler) {
                    try {
                        val thumbnailList = LongSparseArray<Bitmap?>()
                        val mediaMetadataRetriever = MediaMetadataRetriever()
                        mediaMetadataRetriever.setDataSource(context, mVideoUri)
                        // Retrieve media data
                        val videoLengthInMs =
                            (mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)!!
                                .toInt() * 1000).toLong()
                        // Set thumbnail properties (Thumbs are squares)
                        val thumbWidth = mHeightView
                        val thumbHeight = mHeightView
                        val numThumbs = ceil((viewWidth.toFloat() / thumbWidth).toDouble())
                            .toInt()
                        val interval = videoLengthInMs / numThumbs
                        for (i in 0 until numThumbs) {
                            val bitmap: Bitmap? = mediaMetadataRetriever.getFrameAtTime(
                                i * interval,
                                MediaMetadataRetriever.OPTION_CLOSEST_SYNC
                            )?.run {
                                Bitmap.createScaledBitmap(
                                    this,
                                    thumbWidth,
                                    thumbHeight,
                                    false
                                )
                            }
                            thumbnailList.put(i.toLong(), bitmap)
                        }
                        mediaMetadataRetriever.release()
                        returnBitmaps(thumbnailList)
                    } catch (e: Throwable) {
                    }
                }
            }
        
            private fun returnBitmaps(thumbnailList: LongSparseArray<Bitmap?>) {
                onListReady.invoke(thumbnailList)
                this.onListReady = {} // here i reset the listener so that it doesn't get called again
        
                viewScope.launch(Dispatchers.Main) {
                    mBitmapList = thumbnailList
                    invalidate()
                }
            }
        
            override fun onDraw(canvas: Canvas) {
                super.onDraw(canvas)
                if (mBitmapList != null) {
                    canvas.save()
                    var x = 0
                    for (i in 0 until mBitmapList!!.size()) {
                        val bitmap = mBitmapList!![i.toLong()]
                        if (bitmap != null) {
                            canvas.drawBitmap(bitmap, x.toFloat(), 0f, null)
                            x += bitmap.width
                        }
                    }
                }
            }
           //this method recieves the thumbnails list if it's already generated so that you don't generate them twice.
            fun setVideo(data: Uri, thumbnailList: LongSparseArray<Bitmap?>? = null) {
                mVideoUri = data
                mBitmapList = thumbnailList
            }
             // this method is used to get the thumbnails once they are ready, to save them so that i don't recreate them again when onBindViewholder is called again.
            fun getThumbnailListOnce(onListReady: (LongSparseArray<Bitmap?>) -> Unit) {
                this.onListReady = onListReady
            }
        
            init {
                init()
            }
        }
    

    i used corotuine in custom view as suggested here here the extension function for reference

        val View.viewScope: CoroutineScope
            get() {
                val storedScope = getTag(R.string.view_coroutine_scope) as? CoroutineScope
                if (storedScope != null) return storedScope
    
                val newScope = ViewCoroutineScope()
                if (isAttachedToWindow) {
                    addOnAttachStateChangeListener(newScope)
                    setTag(R.string.view_coroutine_scope, newScope)
                } else newScope.cancel()
    
                return newScope
            }
    
        private class ViewCoroutineScope : CoroutineScope, View.OnAttachStateChangeListener {
            override val coroutineContext = SupervisorJob() + Dispatchers.Main
    
            override fun onViewAttachedToWindow(view: View) = Unit
    
            override fun onViewDetachedFromWindow(view: View) {
                coroutineContext.cancel()
                view.setTag(R.string.view_coroutine_scope, null)
            }
        }
    

    i am using this inside viewPager so here is item_video.xml which used in recyclerview adapter

        <?xml version="1.0" encoding="utf-8"?>
        <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
            xmlns:app="http://schemas.android.com/apk/res-auto"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">
        
            <com.google.android.exoplayer2.ui.StyledPlayerView
                android:id="@+id/video_view"
                android:layout_width="0dp"
                android:layout_height="0dp"
                android:layout_gravity="center"
                app:auto_show="true"
                app:controller_layout_id="@layout/custom_exo_overlay_controller_view"
                app:layout_constraintBottom_toTopOf="@id/exoBottomControls"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintVertical_bias="1.0"
                app:repeat_toggle_modes="none"
                app:resize_mode="fixed_width"
                app:surface_type="surface_view"
                app:use_controller="true" />
        
        </androidx.constraintlayout.widget.ConstraintLayout>
    

    and inside your custom_exo_overlay_controller_view you would have something like this

        <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
                xmlns:app="http://schemas.android.com/apk/res-auto"
                xmlns:tools="http://schemas.android.com/tools"
                android:layout_width="match_parent"
                android:layout_height="wrap_content">
        <!--        other controls-->
            <com.myAppName.presentation.widget.TimeLineView
                android:id="@+id/timeLineView"
                android:layout_width="0dp"
                android:layout_height="0dp"
                android:layout_marginTop="6dp"
                app:layout_constraintBottom_toBottomOf="@id/exo_progress"
                app:layout_constraintEnd_toEndOf="@id/exo_progress"
                app:layout_constraintStart_toStartOf="@+id/exo_progress"
                app:layout_constraintTop_toTopOf="@+id/exo_progress"
                tools:background="@drawable/orange_button_selector" />
        
            <com.google.android.exoplayer2.ui.DefaultTimeBar
                android:id="@id/exo_progress"
                android:layout_width="0dp"
                android:layout_height="52dp"
                app:buffered_color="@android:color/transparent"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:played_color="@android:color/transparent"
                app:scrubber_drawable="@drawable/ic_scrubber"
                app:touch_target_height="52dp"
                app:unplayed_color="@android:color/transparent" />
        
        </androidx.constraintlayout.widget.ConstraintLayout>
    

    note that DefaultTimeBar has some attributes as transparent so that thumbnails appears under it.

    and inside viewHolder i have this

        fun bind(video: ChatMediaFile.Video) {
            initializePlayer(video)
            showThumbnailTimeLine(video)
            handleSoundIcon(video)
        }
        private fun showThumbnailTimeLine(video: ChatMediaFile.Video) {
            binding.videoView.findViewById<TimeLineView?>(R.id.timeLineView)?.let {
                if (video.thumbnailList == null) {
                    it.getThumbnailListOnce { thumbnailList ->
                        video.thumbnailList = thumbnailList
                    }
                    video.url.let { url -> it.setVideo(Uri.parse(url)) }
                } else {
                    video.url.let { url -> it.setVideo(Uri.parse(url), video.thumbnailList) }
                }
            }
        }