삽질도사

[안드로이드] ExoPlayer Controller 커스텀하기 및 기능사용하기. 본문

안드로이드

[안드로이드] ExoPlayer Controller 커스텀하기 및 기능사용하기.

전성진블로그 2023. 7. 11. 10:26

일단 결과물입니다.

배속, 스킵, 남은시간, 상하단 바 자동 사라짐

ExoPlayer 라이브러리를 사용하다보면 기본적으로 잘 되어있어서 크게 건드릴게 없지만,
내장된 여러 기능이나 특히 controller를 figma디자인에 맞게 커스텀하기 위해서는 커스텀을 해야하는 경우가 있습니다.

        <com.google.android.exoplayer2.ui.StyledPlayerView
            android:id="@+id/playerView"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:background="@color/black"
            app:auto_show="true"
            app:use_controller="true"
            app:resize_mode="fixed_width"
            app:surface_type="surface_view"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="@id/controller" />

 

app:use_controller="true"  를 사용해서 기본으로 제공하는 controller를 사용하거나, 
false를 설정하여 아래 코드를 추가하면 됩니다. (참고: https://jinha3211.tistory.com/32)

app:controller_layout_id="@layout/커스텀뷰id"

 

다만, 이러한 경우에는 exoplayer 내부에 기본으로 정의된 style과 기능을 사용해야한다는 제한적인 단점이 있는데,
아래의 예제코드(깃허브 남깁니다.)를 사용하시면 player를 상속해서 BaseController를 따로 정의하고 기능을 동적으로

정의해놓았으므로, 디자인 및 기능을 원하는 대로 바꿀 수가 있습니다.

 

*layout_controller.xml

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

<LinearLayout
    android:id="@+id/controller_view"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_gravity="bottom"
    android:layoutDirection="ltr"
    android:background="#CC000000"
    android:orientation="vertical"
    tools:targetApi="28">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="30dp">

        <TextView android:id="@+id/position"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="14sp"
            android:textStyle="bold"
            android:textColor="#FFBEBEBE"
            android:layout_marginStart="6dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"/>

        <com.google.android.exoplayer2.ui.DefaultTimeBar
            android:id="@+id/time_bar"
            android:layout_width="match_parent"
            android:layout_height="15dp"
            android:fadeScrollbars="false"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/position"/>

        <TextView android:id="@+id/duration"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="14sp"
            android:textStyle="bold"
            android:textColor="#FFBEBEBE"
            android:layout_marginEnd="6dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>
    </androidx.constraintlayout.widget.ConstraintLayout>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingTop="4dp"
        android:orientation="horizontal">

        <ImageButton android:id="@+id/rewind"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            style="@style/ExoMediaButton.Rewind"
            app:layout_constraintEnd_toStartOf="@id/play"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>

        <ImageButton
            android:id="@+id/play"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>

        <ImageButton android:id="@+id/forward"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            style="@style/ExoMediaButton.FastForward"
            app:layout_constraintStart_toEndOf="@id/play"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>

        <TextView
            android:id="@+id/play_speed"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="35dp"
            android:textSize="16sp"
            android:textStyle="bold"
            android:textColor="@color/white"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

딱히 주의하거나 봐야할 점은 없습니다. 위 코드는 본인 입맛대로 BaseController의 코드에 알맞게 커스텀하시면 되는 부분입니다.

 

*activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <data>
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity">

        <ImageView
            android:id="@+id/close"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/close_white"
            android:layout_marginStart="20dp"
            android:layout_marginVertical="10dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"/>

        <com.google.android.exoplayer2.ui.StyledPlayerView
            android:id="@+id/playerView"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:background="@color/black"
            app:auto_show="true"
            app:use_controller="false"
            app:resize_mode="fixed_width"
            app:surface_type="surface_view"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="@id/controller" />

        <kr.blog.customexoplayerex.BaseControllerView
            android:id="@+id/controller"
            android:visibility="gone"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toBottomOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

위 코드에서 주목할 점은 BaseControllerView를 custom Controller로 사용해서 아래 쪽에 배치했다는 점과
close버튼으로써 위 쪽에 imageView를 배치했다는 점뿐입니다. 

*BaseController

package kr.blog.customexoplayerex

import android.annotation.SuppressLint
import android.content.Context
import android.transition.Slide
import android.transition.Transition
import android.transition.TransitionManager
import android.util.AttributeSet
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.TextView
import androidx.core.view.isVisible
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.Timeline
import com.google.android.exoplayer2.ui.DefaultTimeBar
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.ui.TimeBar
import com.google.android.exoplayer2.util.Util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Formatter
import java.util.Locale

class BaseControllerView : LinearLayout {

    private lateinit var playerValue: Player
    private var bar: View? = null

    private lateinit var controllerView: LinearLayout
    private lateinit var rewindButton: ImageView
    private lateinit var forwardButton: ImageView
    private lateinit var playButton: ImageButton
    private lateinit var timeBar: DefaultTimeBar
    private lateinit var curPositionView: TextView
    private lateinit var durPositionView: TextView
    private lateinit var playerView: StyledPlayerView
    private lateinit var playSpeedView: TextView

    private lateinit var listener: CustomComponentListener
    private val formatBuilder: StringBuilder by lazy { StringBuilder() }
    private val formatter: Formatter by lazy { Formatter(formatBuilder, Locale.getDefault()) }
    private val window: Timeline.Window by lazy { Timeline.Window() }
    private val updateProgressAction = this::updateProgress

    private var playSpeedPosition = DEFAULT_SPEED_POSITION
    private var coroutineForCancelAndCreate = CoroutineScope(Dispatchers.Main)
    private val speedList = floatArrayOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 2f)

    inner class CustomComponentListener :
        Player.Listener,
        TimeBar.OnScrubListener,
        OnClickListener,
        PopupWindow.OnDismissListener {

        override fun onEvents(player: Player, events: Player.Events) {
            if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED, Player.EVENT_PLAY_WHEN_READY_CHANGED))
                updatePlayPauseButton()

            if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED, Player.EVENT_PLAY_WHEN_READY_CHANGED, Player.EVENT_IS_PLAYING_CHANGED))
                updateProgress()

            if (events.containsAny(Player.EVENT_POSITION_DISCONTINUITY, Player.EVENT_TIMELINE_CHANGED))
                updateTimeline()

            if (events.contains(Player.EVENT_PLAYBACK_PARAMETERS_CHANGED))
                updateSpeedText()
        }

        override fun onScrubStart(timeBar: TimeBar, position: Long) { setTimeText(position) }
        override fun onScrubMove(timeBar: TimeBar, position: Long) { setTimeText(position) }
        override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { seekToTimeBarPosition(position) }
        override fun onClick(view: View?) {
            if(view != playerView) delayVisibleGone()
            when(view){
                playerView -> setControllerVisible()
                playButton -> dispatchPlayPause()
                rewindButton -> playerValue.currentPosition.minus(DEFAULT_SEEK_VALUE).let { playerValue.seekTo(it) }
                forwardButton -> playerValue.currentPosition.plus(DEFAULT_SEEK_VALUE).let { playerValue.seekTo(it) }
                playSpeedView -> setNextSpeed()
            }
        }
        override fun onDismiss() {}
    }

    constructor(context: Context) : super(context) { initView() }
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initView() }
    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initView() }

    private fun initView() {
        val infService = Context.LAYOUT_INFLATER_SERVICE
        val li = context.getSystemService(infService) as LayoutInflater
        val v = li.inflate(R.layout.layout_controller, this, false)
        addView(v)
        init()
    }

    private fun init() {
        controllerView = findViewById(R.id.controller_view)
        rewindButton = findViewById(R.id.rewind)
        forwardButton = findViewById(R.id.forward)
        playButton = findViewById(R.id.play)
        timeBar = findViewById(R.id.time_bar)
        curPositionView = findViewById(R.id.position)
        durPositionView = findViewById(R.id.duration)
        playSpeedView = findViewById(R.id.play_speed)
    }

    fun setPlayer(styledPlayerView: StyledPlayerView) = this.apply {
        playerView = styledPlayerView.apply {
            playerValue = player ?: ExoPlayer.Builder(context).build()
            playerValue.prepare()
            listener = CustomComponentListener()
            updateAll()
        }
        setListener()
    }

    fun setBar(barView: View) = this.apply { bar = barView }

    private fun updateAll() {
        updateTimeline()
        updatePlayPauseButton()
        updateSpeedText()
        setControllerVisible()
    }

    private fun setListener(){
        playerView.setOnClickListener(listener)
        rewindButton.setOnClickListener(listener)
        forwardButton.setOnClickListener(listener)
        playButton.setOnClickListener(listener)
        playSpeedView.setOnClickListener(listener)
        playerValue.addListener(listener)
        timeBar.addListener(listener)
        setOnClickListener(listener)
    }

    private fun updateProgress() {
        if (!isAttachedToWindow) return

        val position =  playerValue.contentPosition
        val bufferedPosition =  playerValue.contentBufferedPosition

        setTimeText(position)
        timeBar.setPosition(position)
        timeBar.setBufferedPosition(bufferedPosition)

        // Cancel any pending updates and schedule a new one if necessary.
        removeCallbacks(updateProgressAction)
        val playbackState = playerValue.playbackState
        if (playerValue.isPlaying) {
            var mediaTimeDelayMs = timeBar.preferredUpdateDelay

            // Limit delay to the start of the next full second to ensure position display is smooth.
            val mediaTimeUntilNextFullSecondMs = 1000 - position % 1000
            mediaTimeDelayMs = mediaTimeDelayMs.coerceAtMost(mediaTimeUntilNextFullSecondMs)

            // Calculate the delay until the next update in real time, taking playback speed into account.
            val playbackSpeed: Float = playerValue.playbackParameters.speed
            var delayMs = if (playbackSpeed > 0) (mediaTimeDelayMs / playbackSpeed).toLong() else MAX_UPDATE_INTERVAL_MS.toLong()

            // Constrain the delay to avoid too frequent / infrequent updates.
            delayMs = Util.constrainValue(
                delayMs,
                DEFAULT_TIME_BAR_MIN_UPDATE_INTERVAL_MS.toLong(),
                MAX_UPDATE_INTERVAL_MS.toLong()
            )
            postDelayed(updateProgressAction, delayMs)
        } else if (playbackState != Player.STATE_ENDED && playbackState != Player.STATE_IDLE) postDelayed(updateProgressAction, MAX_UPDATE_INTERVAL_MS.toLong())
    }

    @SuppressLint("SetTextI18n")
    private fun setTimeText(position: Long){
        val remainingTime = playerValue.duration - position // remaining time
        curPositionView.text = Util.getStringForTime(formatBuilder, formatter, position)
        durPositionView.text = "-${Util.getStringForTime(formatBuilder, formatter, remainingTime)}"
    }

    private fun updateTimeline(){
        val durationMs = playerValue.duration
        timeBar.setDuration(durationMs)
        updateProgress()
    }

    private fun shouldShowPauseButton()
            = playerValue.playbackState != Player.STATE_ENDED && playerValue.playbackState != Player.STATE_IDLE && playerValue.playWhenReady

    private fun dispatchPlayPause() {
        val state: @Player.State Int = playerValue.playbackState
        if ( (state == Player.STATE_IDLE) || (state == Player.STATE_ENDED) || !playerValue.playWhenReady) dispatchPlay()
        else dispatchPause()
    }

    private fun dispatchPlay() {
        val state: @Player.State Int = playerValue.playbackState
        if (state == Player.STATE_IDLE) playerValue.prepare()
        else if (state == Player.STATE_ENDED) seekTo(playerValue, playerValue.currentMediaItemIndex, C.TIME_UNSET)
        playerValue.play()
    }

    private fun dispatchPause() = playerValue.pause()

    private fun seekTo(player: Player, windowIndex: Int, positionMs: Long) = player.seekTo(windowIndex, positionMs)

    private fun updatePlayPauseButton() {
        if (!isAttachedToWindow) return
        if (shouldShowPauseButton()) playButton.setBackgroundResource(R.drawable.pause_circle)
        else playButton.setBackgroundResource(R.drawable.play_circle)
    }

    private fun seekToTimeBarPosition(positionMs: Long) {
        var positionMs = positionMs
        var windowIndex: Int
        val timeline = playerValue.currentTimeline
        if (!timeline.isEmpty) {
            val windowCount = timeline.windowCount
            windowIndex = 0
            while (true) {
                val windowDurationMs = timeline.getWindow(windowIndex, window).durationMs
                if (positionMs < windowDurationMs) break
                else if (windowIndex == windowCount - 1) {
                    // Seeking past the end of the last window should seek to the end of the timeline.
                    positionMs = windowDurationMs
                    break
                }
                positionMs -= windowDurationMs
                windowIndex++
            }
        } else windowIndex = playerValue.currentMediaItemIndex
        seekTo(playerValue, windowIndex, positionMs)
        updateProgress()
    }

    @SuppressLint("SetTextI18n")
    private fun updateSpeedText() = playSpeedView.run {
        val speed = speedList[playSpeedPosition].toString()
        text = "${speed}x"
    }

    private fun setNextSpeed() = speedList.run {
        if(playSpeedPosition == size - 1) playSpeedPosition = 0
        else playSpeedPosition += 1
        setPlaybackSpeed(speedList[playSpeedPosition])
    }

    private fun setPlaybackSpeed(speed: Float) = playerValue.run { playbackParameters = playbackParameters.withSpeed(speed) }

    private fun setControllerVisible() {
        val transition: Transition = Slide(Gravity.BOTTOM)
        transition.duration = 600
        transition.addTarget(this@BaseControllerView)
        TransitionManager.beginDelayedTransition(this@BaseControllerView as ViewGroup, transition)
        if (isVisible) {
            setVisible(false)
            coroutineForCancelAndCreate.cancel()
        }
        else {
            setVisible(true)
            delayVisibleGone()
        }
    }

    private fun delayVisibleGone() {
        coroutineForCancelAndCreate.cancel()
        coroutineForCancelAndCreate = CoroutineScope(Dispatchers.Main)
        coroutineForCancelAndCreate.launch {
            delay(DEFAULT_DISAPPEARED_TIME)
            if(isVisible) setVisible(false)
        }
    }

    private fun setVisible(visible: Boolean) {
        isVisible = visible
        bar?.isVisible = visible
    }

    companion object {
        const val MAX_UPDATE_INTERVAL_MS = 1000
        const val DEFAULT_TIME_BAR_MIN_UPDATE_INTERVAL_MS = 200
        const val DEFAULT_SEEK_VALUE = 5000
        const val DEFAULT_SPEED_POSITION = 3
        const val DEFAULT_DISAPPEARED_TIME = 6000L
    }
}

Controller를 상속받아서 재정의한 코드입니다.
복잡한 것 같지만 위에서 복잡한 부분은 위/아래에 위치한 controller view가 6초뒤에 자동으로 사라지거나 
화면을 클릭할 경우 사라짐/나타남을 구현하기 위한 부분이 가장 복잡하고 나머지 기능적인 부분은 크게 고려할 것 없이 수정하면 됩니다.

주목할 점은 CustomComponent에 리스너가 몰려있어서 거기서 클릭이나 동영상 제어하는 부분을 건드리면 되고, 
seekBar(동영상 구간 부분)나 남은시간을 표시하기 위해 반복적으로 update를 하는 함수가 존재한다는 점 입니다.



*MainActivity

package kr.blog.customexoplayerex

import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.upstream.DefaultDataSource
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
import kr.blog.customexoplayerex.databinding.ActivityMainBinding


class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private var simpleExoPlayer: ExoPlayer? = null
    private var videoUrl = "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = DataBindingUtil.setContentView(this,R.layout.activity_main)
        binding.lifecycleOwner = this
        initPlayer()
    }

    private fun initPlayer() = binding.run {
        close.bringToFront()

        simpleExoPlayer = ExoPlayer.Builder(this@MainActivity).build()
        simpleExoPlayer?.setMediaSource(buildMediaSource(videoUrl))

        playerView.run{
            player = simpleExoPlayer
            controller
                .setPlayer(this)
                .setBar(close)
        }
        close.setOnClickListener { finish() }
    }

    // to get media source from url to attach it into video
    private fun buildMediaSource(videoUrl: String) = ProgressiveMediaSource
        .Factory(DefaultDataSource.Factory(this))
        .createMediaSource(MediaItem.fromUri(videoUrl))

    // pause
    override fun onResume() {
        super.onResume()
        simpleExoPlayer?.playWhenReady = true
    }

    override fun onStop() {
        super.onStop()
        simpleExoPlayer?.stop()
        simpleExoPlayer?.playWhenReady = false
    }

    override fun onDestroy() {
        super.onDestroy()
        simpleExoPlayer?.release()
    }
}

 

deprecated가 된 함수가 많아서 최신으로 업데이트하여 코드를 올렸습니다. (2023/07/11 기준.) 
다른 부분은 다른 블로그의 코드와 거의 똑같고, initPlayer쪽을 보시면 playerView에서 controller를 정의한 것을 보실 수 있는데,
setBar같은 경우엔, 상단에 혹은 어딘가 놓고 싶은 추가적인 뷰가 있다면 해당 뷰가 controller와 함께 나타나고 사라질 수 있도록 해주고,
onClick 리스너를 BaseControllerView에서 정의할 수 있도록 해주는 코드입니다.

 

궁금한 점이나 틀린 부분 있으면 댓글 부탁드립니다. 


*깃허브 (잘 보셨다면 star 한번만 부탁드립니다...🙇‍♂️)

https://github.com/jin7011/CustomedExoPlayerEx