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

354e7028699d4051674c36760b0c4301.png

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

Оглавление

Введение

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

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

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

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

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

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

bubble.xml



    
    230dp
    
    260dp
    
    15dp
    
    3dp
    
    6dp

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

colors.xml



    #FFFFFFFF
    #4debebf5

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

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
) : RecyclerView.Adapter() {

    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

    ...
    

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

bubble_view_controller.xml




    

    

        

    

    

Создадим 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(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 секунде (½ длины видео) длина линии прогресса должна быть ½ от длины линии буффера. Думаю, тут все просто.

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

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 перемотки

b2602080dd5ce24e59bae1aad61efafd.jpg

Добавим стейт с говорящим 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()
    ...
    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))
    }
}

Заключение

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

© Habrahabr.ru