Видео-сообщение как в Telegram. Часть третья — Контролы и раскрытое состояние

от автора

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

Оглавление

Введение

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

Также можете взять git-ветку предыдущей части и сразу понять контекст.

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

Контролы и раскрытое состояние

По аналогии с Telegram’ом, хочется при тапе по сообщению увеличивать его и показывать кнопки плей/паузы и перемотки.

Раскрытие/сжатие видео

Добавим размеры для «кружочка» в ресурсы.

bubble.xml

<?xml version="1.0" encoding="utf-8"?> <resources>     <!--  размер сообщения в сжатом состоянии  -->     <dimen name="bubble_initial_size">230dp</dimen>     <!--  размер сообщения в развернутом состоянии  -->     <dimen name="bubble_expanded_size">260dp</dimen>     <!--  отступ линии прогресса просмотра от краев view  -->     <dimen name="bubble_timebar_buffer_path_padding">15dp</dimen>     <!--  ширина линии прогресса просмотра  -->     <dimen name="bubble_timebar_buffer_path_stroke">3dp</dimen>     <!--  радиус badge перемотки  -->     <dimen name="bubble_timebar_badge_radius">6dp</dimen> </resources>

Добавим цвета.

colors.xml

<?xml version="1.0" encoding="utf-8"?> <resources>     <color name="progress_color">#FFFFFFFF</color>     <color name="buffer_color">#4debebf5</color> </resources>

Добавим методы раскрытия и сжатия сообщения.

BubbleViewHolder.kt

class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {     ...     private var changeSizeAnimator: ValueAnimator? = null     var isActive = false     ...      // сжимает видео когда оно пропадает с экрана     fun makeInactiveImmediately() {         isActive = false         val initialSize = itemView.context.resources.getDimensionPixelSize(R.dimen.bubble_initial_size)         playerView.updateLayoutParams {             height = initialSize             width = initialSize         }     }          // АНИМИРОВАННО сжимает видео при раскрытии другого видео     fun makeInactiveAnimated() {         isActive = false         val initialSize = itemView.context.resources.getDimensionPixelSize(R.dimen.bubble_initial_size)         // отменяем предыдущую анимацию если она еще не закончилась         changeSizeAnimator?.cancel()         changeSizeAnimator = ValueAnimator.ofInt(playerView.height, initialSize).apply {             duration = 500L             interpolator = PathInterpolatorCompat.create(0.33F, 0F, 0F, 1F)             addUpdateListener {                 playerView.updateLayoutParams {                     height = it.animatedValue as Int                     width = it.animatedValue as Int                 }             }         }         changeSizeAnimator?.start()     }      // АНИМИРОВАННО раскрывает видео при тапе по нему     fun makeActive() {         isActive = true         val expandedSize = itemView.context.resources.getDimensionPixelSize(R.dimen.bubble_expanded_size)         // отменяем предыдущую анимацию если она еще не закончилась         changeSizeAnimator?.cancel()           changeSizeAnimator = ValueAnimator.ofInt(playerView.height, expandedSize).apply {             duration = 500L             interpolator = PathInterpolatorCompat.create(0.33F, 0F, 0F, 1F)             addUpdateListener {                 playerView.updateLayoutParams {                     height = it.animatedValue as Int                     width = it.animatedValue as Int                 }             }         }         changeSizeAnimator?.start()     }     ... }

Добавим вызов методов viewHolder’а в BubbleAdapter

BubbleAdapter.kt

package com.example.videobubble  import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView  class BubbleAdapter(     private val items: List<BubbleModel> ) : RecyclerView.Adapter<BubbleViewHolder>() {      private var recyclerView: RecyclerView? = null     ...          override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {         ...         this.recyclerView = recyclerView     }      override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {         ...         this.recyclerView = null     }      override fun onBindViewHolder(holder: BubbleViewHolder, position: Int) {         ...         holder.itemView.setOnClickListener {             // если раскрыто другое сообщение, то сжимаем его             findActiveViewHolder()?.makeInactiveAnimated()             holder.makeActive()         }     }      // поиск уже раскрытого сообщения     private fun findActiveViewHolder(): BubbleViewHolder? {         recyclerView?.let { rv ->             val layoutManager = rv.layoutManager as LinearLayoutManager             val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()             val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()              for (i in firstVisiblePosition ..  lastVisiblePosition) {                 val viewHolder = rv.findViewHolderForAdapterPosition(i) as? BubbleViewHolder                 if (viewHolder?.isActive == true) {                     return viewHolder                 }             }         }         return null     }      // сжимаем сообщение, когда оно исчезает с экрана     override fun onViewDetachedFromWindow(holder: BubbleViewHolder) {         super.onViewDetachedFromWindow(holder)         holder.makeInactiveImmediately()     } }

Готово! Запускаем и проверяем результат.

Контролы

В PlayerView уже реализованы дефолтные кнопки управления. Сейчас они не отображаются потому что мы их скрыли (параметр app:use_controller=»false» в PlayerView).

Если зайти в папку layout внутри исходников ExoPlayer’а, то можно найти файл exo_player_control_view.xml. Внутри мы увидим те самые реализованные кнопки.

Исходный layout контролов

Исходный layout контролов

Давайте подменим эти кнопки на свои. Для этого переопределяем параметр controller_layout_id у PlayerView.

li_bubble.xml

    ...     <com.google.android.exoplayer2.ui.PlayerView         ...         // Созданный нами layout контролов         app:controller_layout_id="@layout/bubble_view_controller"         // Обновление timebar'а каждые 30мс         app:time_bar_min_update_interval="30"         // Отключаем таймер на скрытие timebar'а         app:show_timeout="0"         // Отключаем скрывание timebar'а по нажатию         app:hide_on_touch="false"     ...

Создадим bubble_view_controller, сохранив исходные id у view, чтобы PlayerView корректно использовал их для управления видео.

bubble_view_controller.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="match_parent"     android:layout_height="match_parent">      <com.example.videobubble.BubbleTimeBar         android:id="@id/exo_progress"         android:layout_width="match_parent"         android:layout_height="match_parent"         app:layout_constraintTop_toTopOf="parent"         app:layout_constraintBottom_toBottomOf="parent"         app:layout_constraintStart_toStartOf="parent"         app:layout_constraintEnd_toEndOf="parent"/>      <FrameLayout         android:id="@id/exo_play"         android:layout_width="0dp"         android:layout_height="0dp"         android:layout_margin="60dp"         app:layout_constraintTop_toTopOf="parent"         app:layout_constraintBottom_toBottomOf="parent"         app:layout_constraintStart_toStartOf="parent"         app:layout_constraintEnd_toEndOf="parent">          <ImageButton style="@style/ExoMediaButton.Play"             android:id="@+id/exo_play_icon"             android:layout_height="36dp"             android:layout_width="36dp"             android:scaleType="fitCenter"             android:clickable="false"             android:layout_gravity="center"/>      </FrameLayout>      <View android:id="@+id/exo_pause"         android:layout_width="0dp"         android:layout_height="0dp"         app:layout_constraintTop_toTopOf="parent"         app:layout_constraintBottom_toBottomOf="parent"         app:layout_constraintStart_toStartOf="parent"         app:layout_constraintEnd_toEndOf="parent"/>  </androidx.constraintlayout.widget.ConstraintLayout>

Создадим BubbleTimeBar, который будет отображать прогресс просмотра, а также badge перемотки.

BubbleTimeBar.kt

class BubbleTimeBar @JvmOverloads constructor(     context: Context,     attrs: AttributeSet? = null,     defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr), TimeBar {      // отступ линии буффера от края view     private val padding = resources.getDimensionPixelSize(R.dimen.bubble_timebar_buffer_path_padding).toFloat()     // ширина линии буффера     private val bufferPathStroke = resources.getDimensionPixelSize(R.dimen.bubble_timebar_buffer_path_stroke).toFloat()     // сущность для отрисовки линии буффера     private val playerBufferDrawer = PlayerBufferDrawer(         padding = padding,         bufferPathStroke = bufferPathStroke,         context = context     )      init {         // тк BubbleTimeBar-это viewGroup, то для вызова onDraw необходимо прописать это         setWillNotDraw(false)     }      // уведомляем PlayerBufferDrawer об изменениях размера view     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {         super.onSizeChanged(w, h, oldw, oldh)         playerBufferDrawer.onViewSizeChanged(w, h)     }      // просим PlayerBufferDrawer отрисовать линию буффера     override fun onDraw(canvas: Canvas) {         super.onDraw(canvas)         playerBufferDrawer.drawBuffer(canvas)     }      override fun addListener(listener: TimeBar.OnScrubListener) = Unit     override fun removeListener(listener: TimeBar.OnScrubListener) = Unit     override fun setPosition(position: Long) = Unit     override fun setDuration(duration: Long) = Unit     override fun setKeyTimeIncrement(time: Long) = Unit     override fun setKeyCountIncrement(count: Int) = Unit     override fun setAdGroupTimesMs(adGroupTimesMs: LongArray?, playedAdGroups: BooleanArray?, adGroupCount: Int) = Unit     override fun setBufferedPosition(bufferedPosition: Long) = Unit     override fun getPreferredUpdateDelay() = 0L }

PlayerBufferDrawer.kt

class PlayerBufferDrawer(     private val padding: Float,     private val bufferPathStroke: Float,     context: Context ) {      // path по которому будет отрисовываться круг таймлайна     private val bufferPath = Path()     private val paint = Paint().apply {         // цвет круга         color = ContextCompat.getColor(context, R.color.buffer_color)         // отрисовываем только контур от bufferPath (иначе отрисовывается закрашенный круг)         style = Paint.Style.STROKE         // ширина круга         strokeWidth = bufferPathStroke     }      // обновляем Path при изменении размеров view     fun onViewSizeChanged(width: Int, height: Int) {         bufferPath.reset()         val centerX = width.toFloat() / 2         val centerY = height.toFloat() / 2         val radius = centerX - padding         bufferPath.addCircle(centerX, centerY, radius, Path.Direction.CW)     }      // отрисовываем Path     fun drawBuffer(canvas: Canvas) {         canvas.drawPath(bufferPath, paint)     } }

Добавим анимированное появление BubbleTimeBar.

FadeAnimation.kt

fun View.fadeIn() = createFadeAnimator(0F, 1F)  fun View.fadeOut() = createFadeAnimator(1F, 0F)  private fun View.createFadeAnimator(from: Float, to: Float): ObjectAnimator {     return ObjectAnimator.ofFloat(this, View.ALPHA, from, to).apply {         this.duration = 500L     } }

BubbleViewHolder.kt

import com.google.android.exoplayer2.ui.R as exoPlayerR  class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {          private val playerViewTimeBar = itemView.findViewById<BubbleTimeBar>(exoPlayerR.id.exo_progress)     ...      // сжимает видео когда оно пропадает с экрана     fun makeInactiveImmediately() {         ...         changeControllerVisibility(false)     }      // АНИМИРОВАННО сжимает видео при раскрытии другого видео     fun makeInactiveAnimated() {         changeControllerVisibility(false)         ...         playerViewTimeBar.fadeOut().start()     }      // АНИМИРОВАННО раскрывает видео при тапе по нему     fun makeActive() {         ...         playerViewTimeBar.fadeIn().apply {             doOnStart { changeControllerVisibility(true) }         }.start()     }          private fun changeControllerVisibility(isVisible: Boolean) {         when (isVisible) {             true -> {                 playerView.useController = true                 playerView.showController()             }             false -> {                 playerView.useController = false                 playerView.hideController()             }         }     } }

Отрисовка кнопок и буффера готова! Запускаем и проверяем результат.

Результат

Результат

Теперь добавим отображение прогресса просмотра.

Добавление линии прогресса просмотра

Длина линии будет пропорциональна просмотренной части видео.
Например, видео идет 10 секунд. На 5 секунде (1/2 длины видео) длина линии прогресса должна быть 1/2 от длины линии буффера. Думаю, тут все просто.

Записываем длительность видео и текущую позицию просмотра.

BubbleTimeBar.kt

class BubbleTimeBar @JvmOverloads constructor(     context: Context,     attrs: AttributeSet? = null,     defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr), TimeBar {     ...     private var currentPlayPosition: Float = 0F     var videoDuration: Long = 0     ...      override fun setPosition(position: Long) {        currentPlayPosition = position.toFloat()        // перерисовываем timeBar при обновлении позиции        invalidate()     }      override fun setDuration(duration: Long) {        videoDuration = duration     }     ... }

Чтобы соотнести текущую позицию просмотра с длиной линии прогресса будем использовать PathMeasure, который будет замерять длину круга буффера.

PlayerBufferDrawer.kt

class PlayerBufferDrawer(     ... ) {     ...     private var pathMeasure = PathMeasure(bufferPath, false)      fun onViewSizeChanged(width: Int, height: Int) {         bufferPath.reset()         val centerX = width.toFloat() / 2         val centerY = height.toFloat() / 2         val radius = centerX - padding         bufferPath.addCircle(centerX, centerY, radius, Path.Direction.CW)         // Замеряем длину круга буффера         pathMeasure = PathMeasure(bufferPath, false)     }      fun getBufferPathMeasure(): PathMeasure {         return pathMeasure     }      ... }

Отлично! Теперь у нас есть все для соотношения прогресса с длиной линии.
Добавим сущность, которая будет отрисовывать линию прогресса.

PlayerProgressDrawer.kt

class PlayerProgressDrawer(     private val bufferPathStroke: Float,     context: Context ) {      // Path для отрисовки линии прогресса     private val playProgressPath = Path()     private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {         color = ContextCompat.getColor(context, R.color.progress_color)         strokeWidth = bufferPathStroke         style = Paint.Style.STROKE         strokeCap = Paint.Cap.ROUND     }      fun drawProgress(canvas: Canvas, progress: Float, bufferPathMeasure: PathMeasure) {         playProgressPath.reset()         // получаем длину линии относительно прогресса просмотра         val segmentLength = bufferPathMeasure.length * progress         // Получаем часть круга буффера, равную длине линии прогресса         bufferPathMeasure.getSegment(0F, segmentLength, playProgressPath, true)         // Отрисовываем линию прогресса         canvas.drawPath(playProgressPath, paint)     } }

Добавляем PlayerProgressDrawer в BubbleTimeBar.

BubbleTimeBar.kt

class BubbleTimeBar @JvmOverloads constructor(     ... ) : FrameLayout(context, attrs, defStyleAttr), TimeBar {          ...     private val playerProgressDrawer = PlayerProgressDrawer(       bufferPathStroke = bufferPathStroke,        context = context     )     ...     override fun onDraw(canvas: Canvas) {         super.onDraw(canvas)          playerBufferDrawer.drawBuffer(canvas)         val bufferPathMeasure = playerBufferDrawer.getBufferPathMeasure()         val playProgress = runCatching { currentPlayPosition / videoDuration }.getOrDefault(0F)         playerProgressDrawer.drawProgress(             canvas = canvas,             progress = playProgress,             bufferPathMeasure = bufferPathMeasure         )     }     ... }

Запускаем и видим, что появилась отрисовка прогресса. Но начинается она не сверху, как нам хотелось.

Давайте повернем на 90 градусов path, который отрисовывает прогресс. Для этого создаем матрицу, поворачиваем ее на -90 градусов и применяем к path.

PlayerBufferDrawer.kt

class PlayerBufferDrawer(     ... ) {     ...     fun onViewSizeChanged(width: Int, height: Int) {         bufferPath.reset()         val centerX = width.toFloat() / 2         val centerY = height.toFloat() / 2         val radius = centerX - padding         bufferPath.addCircle(centerX, centerY, radius, Path.Direction.CW)         val matrix = Matrix()         matrix.postRotate(-90F, centerY, centerY)         bufferPath.transform(matrix)         pathMeasure = PathMeasure(bufferPath, false)     }     ... }

Badge перемотки

Добавим стейт с говорящим isVideoPlaying для того чтобы скрывать или показывать badge.

BubbleTimeBar.kt

class BubbleTimeBar @JvmOverloads constructor(     ... ) : FrameLayout(context, attrs, defStyleAttr), TimeBar, Player.Listener {      ...     var isVideoPlaying: Boolean = false     private val badgeDrawer = TimeBarBadgeDrawer(context)     ...     override fun onIsPlayingChanged(isPlaying: Boolean) {        super.onIsPlayingChanged(isPlaying)        isVideoPlaying = isPlaying    }      override fun onDraw(canvas: Canvas) {         super.onDraw(canvas)          ...         if (!isVideoPlaying) {             badgeDrawer.drawBadge(                 canvas = canvas,                 progress = playProgress,                 bufferPathMeasure = bufferPathMeasure             )         }     }      ... }

Добавим логику отрисовки badge на конце линии прогресса.

TimeBarBadgeDrawer.kt

class TimeBarBadgeDrawer(context: Context) {      private val badgeCoordinates = floatArrayOf(0f, 0f)     private val badgeRadius = context.resources.getDimensionPixelSize(R.dimen.bubble_timebar_badge_radius)     private val paint = Paint().apply { color = Color.WHITE }      fun drawBadge(canvas: Canvas, progress: Float, bufferPathMeasure: PathMeasure) {         // Записываем координаты края линии прогресса в badgeCoordinates         bufferPathMeasure.getPosTan(bufferPathMeasure.length * progress, badgeCoordinates, null)         val circleX = badgeCoordinates[0]         val circleY = badgeCoordinates[1]         // Отрисовываем badge на конце линии прогресса         canvas.drawCircle(circleX, circleY, badgeRadius.toFloat(), paint)     } }

Добавляем BubbleTimeBar как lister плеера.

BubbleViewHolder.kt

import com.google.android.exoplayer2.ui.R as exoPlayerR  class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {     ...     private val player = ExoPlayer.Builder(itemView.context).build().apply {         ...         addListener(playerViewTimeBar)     }     ... }

Перемотка по нажатию

Для обработки нажатия реализуем TimeBar.OnScrubListener

TimeBarScrubListener.kt

class TimeBarScrubListener(     private val playerView: PlayerView ) : TimeBar.OnScrubListener {      override fun onScrubStart(timeBar: TimeBar, position: Long) {         // Останавливаем видео вначале перемотки         playerView.player?.pause()         // Перемотка видео         playerView.player?.seekTo(position)     }      override fun onScrubMove(timeBar: TimeBar, position: Long) {         // Перемотка видео         playerView.player?.seekTo(position)     }      override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {         // Воспроизводим видео после перемотки         playerView.player?.play()     } }

Добавляем TimeBarScrubListener в BubbleViewHolder

class BubbleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), CoroutineScope {     ...     init {         playerViewTimeBar.addListener(TimeBarScrubListener(playerView))         ...     }     ... }

Теперь добавим в BubbleTimeBar реализацию методов addListener/removeListener

BubbleTimeBar.kt

class BubbleTimeBar @JvmOverloads constructor(     ... ) : FrameLayout(context, attrs, defStyleAttr), TimeBar, Player.Listener {     ...     var listeners = mutableListOf<TimeBar.OnScrubListener>()     ...     override fun addListener(listener: TimeBar.OnScrubListener) {         listeners.add(listener)     }      override fun removeListener(listener: TimeBar.OnScrubListener) {         listeners.remove(listener)     }     ... }

Добавим класс, который будет обрабатывать нажатия.

TimeBarTouchListener.kt

class TimeBarTouchListener(     padding: Float,     bufferPathStroke: Float ): View.OnTouchListener {      private val calculator = TimeBarTouchCalculator(padding * 2 + bufferPathStroke)      override fun onTouch(v: View, e: MotionEvent): Boolean {         if (v !is BubbleTimeBar) return false         when (e.action) {             // начало touch-события             MotionEvent.ACTION_DOWN -> {                 if (!calculator.isEventInRewindArea(v, e.x, e.y) || v.isVideoPlaying) return false                 v.parent.requestDisallowInterceptTouchEvent(true)                 v.listeners.forEach { it.onScrubStart(v, calculator.getNewScrubPosition(v, e.x, e.y)) }             }             // touch-событие движения пальца по экрану             MotionEvent.ACTION_MOVE -> v.listeners.forEach {                 it.onScrubMove(v, calculator.getNewScrubPosition(v, e.x, e.y))             }             // конец touch-событие по системным причинам             MotionEvent.ACTION_CANCEL -> v.listeners.forEach {                 it.onScrubStop(v, calculator.getNewScrubPosition(v, e.x, e.y), true)             }             // конец touch-события             MotionEvent.ACTION_UP -> v.listeners.forEach {                 it.onScrubStop(v, calculator.getNewScrubPosition(v, e.x, e.y), false)             }         }         return true     }      internal class TimeBarTouchCalculator(         private val rewindAreaSize: Float     ) {          /**          * Вычисляем позицию перемотки          * Шаги вычисления:          * 1. Вычслили угол между векторами A и B, где          *      A - вектор из центра вью ровно вверх (0,-1)          *      B - вектор из центра вью до точки [MotionEvent]          * 2. По формуле пропорции полученного угла к 360 градусам получаем новую позицию          */         fun getNewScrubPosition(view: BubbleTimeBar, touchX: Float, touchY: Float) : Long {             val x1 = 0F             val y1 = -1F              val x2 = touchX - view.width.toFloat() / 2             val y2 = touchY - view.height.toFloat() / 2              val cosAngle = (x1 * x2 + y1 * y2) / (sqrt(x1 * x1 + y1 * y1) * sqrt(x2 * x2 + y2 * y2))             val angle = when {                 x2 < 0 -> 360F - Math.toDegrees(acos(cosAngle).toDouble())                 else -> Math.toDegrees(acos(cosAngle).toDouble())             }             return (view.videoDuration * angle / 360F).toLong()         }          /**          * Проверяем находится ли [MotionEvent] в зоне перемотки          * Шаги проверки:          * 1. Вычисляем расстоляние от центра вью до точки касания          * 2. Сравниваем полученное расстояние с радиусом вью за вычетом зоны перемотки          */         fun isEventInRewindArea(view: BubbleTimeBar, touchX: Float, touchY: Float): Boolean {             val centerX = view.width / 2F             val centerY = view.height / 2F             val distance = sqrt((centerX - touchX).pow(2) + (centerY - touchY).pow(2))             return distance >= centerY - rewindAreaSize         }     } }

Добавим TimeBarTouchListener в BubbleTimeBar.

BubbleTimeBar.kt

class BubbleTimeBar @JvmOverloads constructor(     context: Context,     attrs: AttributeSet? = null,     defStyleAttr: Int = 0 ) : FrameLayout(context, attrs, defStyleAttr), TimeBar, Player.Listener {     ...     init {         ...         setOnTouchListener(TimeBarTouchListener(padding, bufferPathStroke))     } }

Заключение

Поздравляю! Мы сделали видео-сообщения. Соберите и посмотрите на результат.


ссылка на оригинал статьи https://habr.com/ru/articles/776522/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *