안드로이드 - 리싸이클러뷰 Exoplayer

이우건·2024년 2월 19일
0

안드로이드

목록 보기
13/20

서버에서 받은 video url을 리싸이클러뷰에 렌더링을 하는 과정이다.
video를 재생시키는 방법은 Exoplayer 라이브러리를 채택했으며 Android 플랫폼을 위한 오픈 소스, 어플리케이션 레벨의 미디어 플레이어이다.

build.gradle(App Module)

	// Exoplayer
    implementation 'com.google.android.exoplayer:exoplayer-core:2.15.1'
    implementation 'com.google.android.exoplayer:exoplayer-dash:2.15.1'
    implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.1'

원래는 2.18.1 버전을 사용했으나 SimpleExoPlayer가 deprecated되었고 이 프로젝트에서는 간단히 영상만 재생하는 용도로 사용하였으므로 버전을 낮추고 SimpleExoPlayer를 사용하였다.

리싸이클러뷰 item 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">

    <com.google.android.exoplayer2.ui.PlayerView
        android:id="@+id/player_view_pamphlet"
        android:layout_width="match_parent"
        android:layout_height="400dp"
        android:layout_marginStart="20dp"
        android:layout_marginTop="100dp"
        app:resize_mode="zoom"
        android:layout_marginEnd="20dp"
        android:layout_marginBottom="60dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/layout_pamphlet_detail_image_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="@id/player_view_pamphlet"
        app:layout_constraintTop_toTopOf="@id/player_view_pamphlet">

        <ImageView
            android:id="@+id/iv_pamphlet_detail"
            android:layout_width="match_parent"
            android:layout_height="400dp"
            android:layout_marginStart="20dp"
            android:layout_marginEnd="20dp"
            android:scaleType="centerCrop"
            app:layout_constraintTop_toTopOf="parent"
            tools:src="@drawable/test" />

        <ImageView
            android:id="@+id/iv_pamphlet_detail_emoji"
            android:layout_width="70dp"
            android:layout_height="70dp"
            android:elevation="4dp"
            app:layout_constraintStart_toStartOf="@id/iv_pamphlet_detail"
            app:layout_constraintTop_toTopOf="@id/iv_pamphlet_detail"
            tools:src="@drawable/smile" />

    </androidx.constraintlayout.widget.ConstraintLayout>

    <TextView
        android:id="@+id/tv_pamphlet_detail_text"
        style="@style/Text.Input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginStart="20dp"
        android:layout_marginTop="30dp"
        android:layout_marginEnd="20dp"
        android:background="@drawable/background_record_text"
        android:gravity="center"
        android:maxLines="2"
        android:padding="10dp"
        app:layout_constraintTop_toBottomOf="@id/layout_pamphlet_detail_image_text"
        tools:text="아름다운 바다에서 한 컷" />


    <TextView
        android:id="@+id/tv_item_pamphlet_detail_page"
        style="@style/Text.Input"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="10dp"
        android:letterSpacing="0.2"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        tools:text="3/4" />
</androidx.constraintlayout.widget.ConstraintLayout>

리싸이클러뷰 아이템에서 이미지와 영상을 번갈아가며 보여주기 위해 playerView와 imageView를 같이 넣어주었다.

아이템은 한 화면에 한 아이템으로 꽉 채울 만큼의 크기를 지정해주었다.

리싸이클러뷰 adatper.kt

class PamphletDetailAdapter(private val context : Context, private val viewModel : MyRecordDetailViewModel) : ListAdapter<Record, PamphletDetailAdapter.PamphletDetailViewHolder>(MyRecordDetailDiffUtil()) {

    private val items: List<Record>
        get() = currentList
    private var playWhenReady = true
    private var currentWindow = 0
    private var playbackPosition = 0L
    private var player : SimpleExoPlayer? = null

    inner class PamphletDetailViewHolder(private val binding : ItemPamphletDetailBinding) : RecyclerView.ViewHolder(binding.root) {
        @SuppressLint("SetTextI18n")
        fun bind(item: Record, position : Int, listSize : Int) {
            // 이미지 일 경우
            if (item.imgUrl.isNotEmpty() && item.videoUrl.isEmpty()) {
                binding.playerViewPamphlet.visibility = View.INVISIBLE

                setImageItem(item)
            }
            // 비디오 일 경우
            else if (item.imgUrl.isNotEmpty() && item.videoUrl.isNotEmpty()) {
                binding.ivPamphletDetail.visibility = View.INVISIBLE
                binding.playerViewPamphlet.visibility = View.VISIBLE
                initializePlayer(item.videoUrl)
            }

            binding.tvPamphletDetailText.text = item.text
            binding.tvItemPamphletDetailPage.text = "${position+1} / $listSize"
        }

        /**
         * 이미지를 렌더링 하는 함수
         */
        private fun setImageItem(record : Record) {
            Glide.with(context)
                .load(record.imgUrl)
                .into(binding.ivPamphletDetail)

            Glide.with(context)
                .load(MyRecordDetailFragment.emojiDrawableId[record.emoji])
                .into(binding.ivPamphletDetailEmoji)

        }

        /**
         * 여기부터  Exoplayer 설정 함수
         */
        fun initializePlayer(uri: String) {
            player = SimpleExoPlayer.Builder(context)
                .build()
                .also { exoPlayer ->
                    binding.playerViewPamphlet.player = exoPlayer

                    val mediaItem = MediaItem.fromUri(uri)
                    exoPlayer.setMediaItem(mediaItem)

                    exoPlayer.playWhenReady = playWhenReady
                    exoPlayer.seekTo(currentWindow, playbackPosition)
                    exoPlayer.prepare()
                    exoPlayer.play()
                }
        }

        fun releasePlayer() {
            player?.run {
//                playbackPosition = this.currentPosition
//                currentWindow = this.currentWindowIndex
//                playWhenReady = this.playWhenReady
                stop() // 플레이어를 정지합니다.
                seekTo(0) // 재생 위치를 0으로 설정합니다.
                playbackPosition = 0 // 재생 위치 변수를 0으로 재설정합니다.
                currentWindow = 0 // 현재 윈도우 인덱스를 0으로 재설정합니다.
                release()
            }
            player = null
        }

    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PamphletDetailViewHolder {
        val binding = ItemPamphletDetailBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return PamphletDetailViewHolder(binding)
    }

    override fun onBindViewHolder(holder: PamphletDetailViewHolder, position: Int) {
        holder.bind(getItem(position), position, itemCount)
    }

    override fun onViewRecycled(holder: PamphletDetailViewHolder) {
        super.onViewRecycled(holder)
        holder.releasePlayer()
    }

    fun getItemAtPosition(position: Int): Record? = items.getOrNull(position)


    companion object {
        val emojiDrawableId = mapOf<String, Int>("HAPPY" to R.drawable.happy, "SMILE" to R.drawable.smile, "SOSO" to R.drawable.soso,
            "SAD" to R.drawable.sad, "ANGRY" to R.drawable.angry)
    }
}

ExoPlayer로 비디오를 재생시키기 위해서는 SimpleExoPlayer.Builder를 통해 객체를 만들고 mediaItem을 붙혀줘야한다. ExoPlayer를 재생시키고 더 이상 사용하지 않을 때는 release를 통해 ExoPlayer를 해제시키고 메모리 누수를 방지해야한다.

처음에는 item이 image일 경우 releasePlayer()를 호출하여 player를 해제시키고 item이 video일 경우 initialPlayer()를 호출하여 player를 만들었지만 영상 -> 이미지로 아이템이 바뀌고 다시 이전 이미지로 돌아올 경우 까만화면만 보이고 영상이 다시 재생되지 않는 이슈가 있었다.

fragment.kt

private fun setAdapter() {
        adapter = PamphletDetailAdapter(requireContext(), myRecordDetailViewModel)
        recyclerView = binding.rvPamphletDetail
        recyclerView.adapter = adapter

        /**
         * 리싸이클러뷰 슬라이딩을 페이지 고정처럼 설정
         */
        val snapHelper = PagerSnapHelper()
        snapHelper.attachToRecyclerView(recyclerView)

        myRecordDetailViewModel.getMyAllRecord(pamphletId)
    }

    private fun observeLiveData() {
        myRecordDetailViewModel.myAllRecord.observe(viewLifecycleOwner) { recordList ->
            adapter.submitList(recordList.toMutableList())
        }
    }

    /**
     * 리싸이클러뷰 아이템이 나타나고 사라짐을 감지하는 이벤트
     */
    private fun recyclerViewEvent() {
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener(){
            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
                super.onScrollStateChanged(recyclerView, newState)

                if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                    val layoutManager = recyclerView.layoutManager as LinearLayoutManager
                    val visiblePosition = layoutManager.findFirstCompletelyVisibleItemPosition()
                    val item = adapter.getItemAtPosition(visiblePosition)


                    if (visiblePosition != RecyclerView.NO_POSITION) {
                        val visibleHolder = recyclerView.findViewHolderForAdapterPosition(visiblePosition) as? PamphletDetailAdapter.PamphletDetailViewHolder

                        visibleHolder?.initializePlayer(item!!.videoUrl)

                        // 다른 아이템의 ExoPlayer를 정지합니다.
                        for (i in 0 until recyclerView.childCount) {
                            val child = recyclerView.getChildAt(i)
                            val holder = recyclerView.getChildViewHolder(child)
                            if (holder is PamphletDetailAdapter.PamphletDetailViewHolder && holder != visibleHolder) {
                                holder.releasePlayer()
                            }
                        }

                    }
                }
            }
        })
    }

리싸이클러뷰 아이템 애니메이션이 다음 애니메이션으로 넘어갈 때 아이템 포커스를 내가 원하는대로 잡기가 힘들어서 PagerSnapHelper()를 리싸이클러뷰에 붙혀서 viewPager처럼 동작하게 만들어주었다.

영상을 재생하고 아이템을 넘긴 뒤 다시 이전 아이템으로 되돌아올 때 영상이 재생되지 않는 이슈를 해결하기 위해 recyclerViewEvent() 함수에서 어느 한 아이템이 포커싱되면 ExoPlayer를 초기화하고 포커싱 된 아이템을 제외한 모든 아이템을 release하는 식으로 구현했다.

이렇게 구현했을 때 불필요한 영상까지 초기화하고 해제하는 식의 리소스 낭비가 되겠지만 간단한 프로젝트의 경우 임기응변용으로 사용하면 좋을 것 같다.

profile
머리가 나쁘면 기록이라도 잘하자

0개의 댓글