如何像 kinemaster 一样创建带有缩略图的视频搜索栏?在 Android?

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

我正在尝试使用 exoplayer 实现上述目标。

以特定间隔从视频创建缩略图列表。说10秒 并将其与时间一起显示到搜索栏。

如何实现? 处理大文件时需要考虑哪些事项? 是先创建所有缩略图好,还是在我们搜索视频时生成缩略图好?

我们如何像上图那样关联时间和相应的缩略图。 此处这些图像应显示在 4s-8s 之间 我们该怎么做?我不知道如何使用常规的 recyclerview 来实现。我们如何使用自定义视图来做到这一点?

问题很多,如有帮助将不胜感激。比你

这里是video timmer library的自定义视图,对协程进行了一些修改和使用,代码中也包含有用的注释

    // 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()
        }
    }

我按照建议在自定义视图中使用了 corotuine here 这里的扩展函数供参考

    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)
        }
    }

我在 viewPager 中使用它,所以这里是 item_video.xml,它在 recyclerview 适配器中使用

    <?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>

在你的custom_exo_overlay_controller_view里面你会有这样的东西

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

请注意,DefaultTimeBar 具有一些透明属性,以便缩略图显示在其下方。

在 viewHolder 里面我有这个

    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) }
            }
        }
    }