
Часто ли вы пользуетесь 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. Внутри мы увидим те самые реализованные кнопки.
Давайте подменим эти кнопки на свои. Для этого переопределяем параметр 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/
Добавить комментарий