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