Часто ли вы пользуетесь Telegram?
Если да, то скорее всего вы хотя бы раз отправляли "кружочки". В этой серии статьей мы напишем небольшой проект с отображением списка видео-сообщений.
Для отображения будем использовать ExoPlayer, настроим сохранение видео в кеш, а также напишем свой TimeBar для управления видео.

Оглавление

Введение

Вначале каждой части я прикреплю git-ветку, в которой будут изменения, описанные в статье. Вы можете либо сразу скачать ее и запустить проект, либо самостоятельно пошагово писать код.

Git-ветки первой части.

Настройка проекта

Так как кружок будет элементом чата, стоит позаботиться о его корректном отображении внутри RecyclerView. Добавим его в верстку нашего экрана.

activity_main.xml

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/bubbles_rv"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

Создаем data-модель нашего видео сообщения с ссылкой на видео.

BubbleModel.kt

class BubbleModel(val videoUrl: String)

Подключим библиотеку ExoPlayer к проекту.

app/build.gradle.kts

...

dependencies {
    ...
    implementation("com.google.android.exoplayer:exoplayer:2.16.1")
    implementation("com.google.android.exoplayer:extension-okhttp:2.16.1")
}

Создаем верстку элемента списка

li_bubble.xml

<?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="wrap_content"
    android:layout_height="wrap_content"
    android:layout_margin="50dp">

    <com.google.android.exoplayer2.ui.PlayerView
        android:id="@+id/li_bubble_player_view"
        android:layout_width="220dp"
        android:layout_height="220dp"
        app:resize_mode="zoom"
        app:surface_type="texture_view"
        app:use_controller="false"
        app:shutter_background_color="@android:color/transparent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
  • app:resize_mode="zoom" - то как видео будет подстраиваться под размеры плеера.

  • app:surface_type="texture_view" - тип view на котором будет отрисовываться наше вью. Почему-то при дефолтном surface_view ячейки recyclerView накладываются друг на друга. Поэтому используем texture_view.

  • app:use_controller="false" - скрываем контроллы видео (кнопка плей, паузы, перемотки итд).

  • app:shutter_background_color="@android:color/transparent" - задаем прозрачный цвет у фона плеера.

Для отображения элементов списка нам понадобится реализация RecyclerView.ViewHolder.

BubbleViewHolder.kt

class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

    private val playerView = itemView.findViewById<PlayerView>(R.id.li_bubble_player_view)

    fun bind(model: BubbleModel) {
      // реализация будет ниже
    }
}

Для управления viewHolder'ами добавим реализацию RecyclerView.Adapter.

BubbleAdapter.kt

class BubbleAdapter(
    private val items: List<BubbleModel>
) : RecyclerView.Adapter<BubbleViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BubbleViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val view = inflater.inflate(R.layout.li_bubble, parent, false)
        return BubbleViewHolder(view)
    }

    override fun getItemCount() = items.size

    override fun onBindViewHolder(holder: BubbleViewHolder, position: Int) {
        holder.bind(items[position])
    }
}

Подключим adapter к recyclerView и добавим 30 видео-сообщений.

MainActivity.kt

class MainActivity : AppCompatActivity() {

    private val items = mutableListOf<BubbleModel>().apply {
        repeat(30) {
            add(BubbleModel("https://i.imgur.com/3Y8IRmz.mp4"))
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val recyclerView = findViewById<RecyclerView>(R.id.bubbles_rv)
        recyclerView.adapter = BubbleAdapter(items)
    }
}

Для загрузки видео нам потребуются права на доступ в сеть.

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest>

    <uses-permission android:name="android.permission.INTERNET"/>

    <application
      ...
    </application>
</manifest>

Добавим кеш для отображения загруженных видео.

VideoCache.kt

private const val DOWNLOAD_CONTENT_DIRECTORY = "inner_video_cache"
private const val MAX_CACHE_SIZE_IN_BYTES = 100 * 1024 * 1024

object VideoCache {

    private var cache: SimpleCache? = null

    fun getInstance(context: Context): SimpleCache {
        return cache ?: run {
            //путь до файла в котором будет храниться кеш видео
            val cacheDir = File(context.externalCacheDir, DOWNLOAD_CONTENT_DIRECTORY)
            //стратегия очистки кеша (очистка последнего использованного кеша)
            val evictor = LeastRecentlyUsedCacheEvictor(MAX_CACHE_SIZE_IN_BYTES.toLong())
            val databaseProvider = StandaloneDatabaseProvider(context)
            SimpleCache(cacheDir, evictor, databaseProvider).apply {
                cache = this
            }
        }
    }
}

За загрузку видео внутри библиотеки ExoPlayer отвечает MediaSource. Добавим фабрику для этой сущности.

MediaSourceCreator.kt

class MediaSourceFactory(
    private val context: Context
) {

    private val mediaSourceFactory by lazy {
        val cacheSink = CacheDataSink.Factory().setCache(VideoCache.getInstance(context))
        val upstreamFactory = DefaultDataSource.Factory(context, DefaultHttpDataSource.Factory())
        val cacheDataSourceFactory = CacheDataSource.Factory()
            //то куда будет сохраняться наш кеш. Если не указывать, то кеш будет read-only
            .setCacheWriteDataSinkFactory(cacheSink)
            //собственно сам кеш
            .setCache(VideoCache.getInstance(context))
            //то откуда будет подргужаться наше видео
            .setUpstreamDataSourceFactory(upstreamFactory)
            //игнорируем ошибки при зависи в кеш
            .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
        //Нужно выбрать MediaSource в соответсвии с форматом видео. Для большинства форматов подходит ProgressiveMediaSource
        ProgressiveMediaSource.Factory(cacheDataSourceFactory)
    }

    fun createMediaSource(url: String): MediaSource {
        return mediaSourceFactory.createMediaSource(MediaItem.fromUri(url))
    }
}

Добавим загрузку видео внутри BubbleViewHolder.

BubbleViewHolder.kt

class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

    private val mediaSourceFactory = MediaSourceFactory(itemView.context)
    private val playerView = itemView.findViewById<PlayerView>(R.id.li_bubble_player_view)
    // плеер, отвечающий за взаимодействие с видео
    private val player = ExoPlayer.Builder(itemView.context).build().apply {
        //настройка повтора видео. В нашем случае воспроизводим одно видео по кругу
        repeatMode = ExoPlayer.REPEAT_MODE_ONE
    }

    init {
        playerView.player = player
    }

    fun bind(model: BubbleModel) {
        val mediaSource = mediaSourceFactory.createMediaSource(model.videoUrl)
        player.setMediaSource(mediaSource)
        //начинает загрузку видео
        player.prepare()
        //начинаем воспроизведение как только видео загрузится
        player.play()
    }
}

Заключение

Все готово! Запускаем проект и сразу видим несколько проблем:

  • Видео не успевают воспроизвестись при прокрутке элементов.

  • При быстрой прокрутке проседает fps.

В следующей части мы займемся исправлением этих проблем.

Читать далее: Часть 2. Оптимизация

Комментарии (0)