[Android] ExoPlayer 를 활용한 비디오 리스트,썸네일 표현

그렌실·2023년 3월 16일

피드 리스트가 유튜브처럼 동영상 목록이 보여지고 스크롤 하다가 멈췄을 때
가장 중간에 위치한 Item를 미리보기 형식으로 재생할 수 있어야 했다.
당연히 스크롤 위치가 바뀐다면 재생되는 영상도 바뀌어야한다.
또 그러면서 재생중이 아닌 동영상은 썸네일로 표현해줘야 했다.
비디오에 관한 처리가 처음이었기에 헤맸고 최근 리팩토링한 내용을 기록해보려고한다.
아직도 완벽하진 않지만 그 간 기록들을 적어보려고 한다.

1. View - 피드 데이터 요청 및 처리

가장 먼저 피드 리스트에 접근하면 viewmodel에게 피드 리스트를 요청해주었다.
참고로 두번째 줄은 room DB를 활용해서 북마크 기능을 만든 것이다.

override fun initData() {
        viewModel.getFeedList("all")
        db = FeedStorageDatabase.getInstance(requireContext())!!
    }
    

위와 같은 식으로 BaseFragment에서 상속받은 initData 메소드를 오버라이딩 해주었다.
그리고 마찬가지로 BaseFragment에서의 bind() 에서 observe 하였다.

viewModel.feedsPrevList.observe(this) {
            feedPrevList = it as ArrayList<FeedListPrevModel>
            resumeFeedList()
        }
  

viewmodel은 Feedrepository를 타고 api를 요청한다. 받아온 liveData를 view로 가져왔고 resumeFeedList() 메서드를 통해 adapter로 연결해주었다.

2. Adapter - 피드 리스트의 중앙 item 찾기 및 썸네일,비디오 처리

-피드 리스트의 타입이 총 3개였다. 텍스트,이미지,비디오 세 가지다.

   override fun getItemViewType(position: Int): Int {
        //return position
        if (contentList[position].contentimg.isNullOrEmpty() && contentList[position].contentvideo.isNullOrEmpty()) {
            return TEXT_TYPE
        }
        //사이즈가
        else if (contentList[position].contentvideo!!.contains("video")) {
            return VIDEO_TYPE
        } else {
            return IMAGE_TYPE
        }
    }

우리는 s3의 url이 비디오 일시에는 항상 video/.mp4 등으로 오기 때문에 위와 같이 분기를 만들었다. 조금 더 좋은 방법이 있을까 생각해봤지만 오히려 size나 empty,null 등을 체크하는 것보다 정확한 것 같았다.

 override fun onBindViewHolder(
       holder: BaseViewHolder,
       position: Int
   ) {
       when (holder.itemViewType) {
           IMAGE_TYPE -> {
               (holder as FeedPrevImageListViewHolder).bind(contentList[position], position)
           }
           TEXT_TYPE, VIDEO_TYPE -> {
               (holder as FeedPrevThumbnailViewHolder).bind(contentList[position], position)
           }
       }
   }

이미지는 여러개를 올릴 수 있어야 했기 때문에 FeedPrevImageListViewHolder 에서는 viewpager2를 사용했고 텍스트/비디오의 경우는 FeedPrevThumbnailViewHolder 를 videoView로 표현해야 했다. (비디오가 없는 경우엔 알아서 text만 표현되게 구현)

fun bind(item: FeedListPrevModel, position: Int) {
           //thumnail을 먼저표현...
           vd.ivThumbnail.layoutParams.height = getHeightSize()
           vd.screenPlayerView.layoutParams.height = getHeightSize()
           processThumbnail(item.contentimg)
           //processVideoDuration(item.contentvideo!!)
           processImage(item.simage, item.writer!!.gender, item.feedlevel)
           processTime(item.time)
           if (item.sname.isNullOrEmpty()) {
               vd.tvNickname.text = "      "
           } else {
               vd.tvNickname.text = item.sname
           }

           processVideo(item.contentprevideo!!)
 			...
 }

FeedPrevThumbnailViewHolder 안에 있는 bind 함수 중 일부이다.
가장 먼저 썸네일을 표현하였는데 getHeightSize 는 우린 항상 16:9 비율로 보이길 원했기에
이런식으로 코드를 짜서 16:9 화면으로 썸네일이든 비디오든 보여주기로 하였다.
크기가 맞지 않을 경우 원본 비율을 유지하게끔 centerCrop으로 scaleType을 설정하였다.
참고로 썸네일은 피드 작성 할때 원본 비디오를 활용해서 ffMpeg 라이브러리를 통해 따왔다.
이건 나중에 피드 작성에 대해 포스팅 할 때 올려보도록 하겠다.

private fun getHeightSize(): Int {

         var screenWidth = DimenUtil.getWindowWidth(context.resources.displayMetrics)
         var screenHeight = DimenUtil.getWindowHeight(context.resources.displayMetrics)
         return (9 * screenWidth) / 16

     }

fun processVideo(uri: String) {
          // initialize mediaPlayer here

          val trackSelector = DefaultTrackSelector(context).apply {
              setParameters(buildUponParameters().setMaxVideoSizeSd())
          }
          _player = ExoPlayer.Builder(context)
              .setTrackSelector(trackSelector)
              .build()
              .also { exoPlayer ->
                  //setProgress(true)
                  vd.screenPlayerView.player = exoPlayer
                  vd.screenPlayerView.controllerAutoShow = false
                  vd.screenPlayerView.controllerShowTimeoutMs = 2000
                  vd.screenPlayerView.hideController()
                  exoPlayer.volume = 0.0f

                  val mediaItem = MediaItem.fromUri("${ImageUtil.baseVideoUrl}$uri")
                  exoPlayer.setMediaItem(mediaItem)
                  exoPlayer.repeatMode = ExoPlayer.REPEAT_MODE_ONE
                  exoPlayer!!.prepare()
                  exoPlayer.addListener(playbackStateListener(this))
                  //exoPlayer!!.prepare()
                  //exoPlayer!!.play()
              } 
      }

위 코드는 exoPlayer를 활용해서 비디오를 재생하였는데
위 코드의 대부분 내용은 그냥 간단히 어떤 식으로 영상을 재생할지에 대한 세팅이다.
중요한 부분은 exoPlayer.addListener(playbackStateListener(this)) 이 부분인데
이 부분이 바로 재생 중인지 아닌지에 따라 썸네일 , 동영상 재생 을 전환해주는 코어 부분이다.

fun playbackStateListener(viewholder : FeedPrevThumbnailViewHolder) = object : Player.Listener {
       override fun onPlaybackStateChanged(playbackState: Int) {
           when (playbackState) {
               ExoPlayer.STATE_IDLE -> "ExoPlayer.STATE_IDLE      -"
               ExoPlayer.STATE_BUFFERING -> "ExoPlayer.STATE_BUFFERING      -"
               ExoPlayer.STATE_READY -> {
                   "ExoPlayer.STATE_READY      -"
                   val durationInMillis = viewholder._player!!.duration
                   val seconds = (durationInMillis / 1000).toInt() % 60
                   val minutes = (durationInMillis / (1000 * 60) % 60).toInt()
                   val duration = String.format("%02d:%02d", minutes, seconds)
                   viewholder.vd.tvRemainTime.text = duration
               }
               ExoPlayer.STATE_ENDED -> "ExoPlayer.STATE_ENDED     -"
               else -> "UNKNOWN_STATE             -"

           }

       }
       override fun onRenderedFirstFrame() {
           super.onRenderedFirstFrame()
           if (viewholder._player == null) return
       }

       override fun onIsPlayingChanged(isPlaying: Boolean) {
           super.onIsPlayingChanged(isPlaying)
           when (isPlaying) {
               true -> {
                   viewholder.vd.ivThumbnail.visibility = View.GONE
                   viewholder.vd.layoutMute.visibility = View.VISIBLE
                   viewholder.vd.layoutRemainTime.visibility = View.VISIBLE
                   viewholder.vd.progressHorizontal.visibility = View.VISIBLE
                   getProgressPercent()
                   //viewholder.vd.screenPlayerView.postDelayed(this::getProgressPercent, 1000)
               }
               else -> {
                   viewholder.vd.ivThumbnail.visibility = View.VISIBLE
                   viewholder.vd.layoutMute.visibility = View.GONE
                   viewholder.vd.layoutRemainTime.visibility = View.GONE
                   viewholder.vd.progressHorizontal.visibility = View.GONE
               }
           }
       }
}

가장 먼저 영상 재생이 준비 되면 prepare 호출 이후
-> STATE_READY 가 되기 때문에 영상에 대한
정보를 가져올 수 있다. 유튜브 처럼 전체 재생 길이를 표현해주고
1초씩 줄이길 원했기에 getProgressPercent() 함수로 표현해주었다. (postDelayed 사용)
우리는 30분 이 동영상 제한 길이였기 때문에 mm:SS 식으로 표현!

onIsPlayingChanged 는 재생상태가 바뀔때마다 보여줄 뷰와 숨길 뷰를 처리하면 됐다.
썸네일은 무조건 로드되어야 했기 때문에 썸네일 imageView의
translationZ="1dp"로 설정했고 VISIBLE,GONE 처리만 해주었다.
비디오는 포커스를 잃었을 때 자동 중지되며 썸네일이 위로 올라오는 방식.

자 그러면 이제 또 중요한 도대체 어떻게 포커스에 따라 정지하고 재생이 되는지 부분이다.

override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
    super.onAttachedToRecyclerView(recyclerView)
    recyclerView.addOnScrollListener(onScrollListener)
}

리싸이클러뷰의 item이 onAttached 되었을 때 스크롤 리스너 하나를 붙였다.(감지 역할)

private val onScrollListener = 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
              var centerPosition = -1
              val firstCmpPos: Int = layoutManager.findFirstCompletelyVisibleItemPosition()
              val lastCmpPos: Int = layoutManager.findLastCompletelyVisibleItemPosition()
              if (firstCmpPos == 0) {
                  centerPosition = 0
              }
              else if (lastCmpPos == contentList.size - 1) {

                  centerPosition = contentList.size-1
              }
              else {
                  val middle = abs(lastCmpPos - firstCmpPos) / 2 + firstCmpPos
                  if (middle >= 0) {
                      centerPosition = middle
                  }
              }
              if (centerPosition != RecyclerView.NO_POSITION) {
                  var viewHolder = recyclerView.findViewHolderForAdapterPosition(centerPosition)
                  if (viewHolder != null && viewHolder.itemViewType != IMAGE_TYPE) {
                      viewHolder = viewHolder as FeedPrevThumbnailViewHolder
                      if (viewHolder != currentPlayingViewHolder) {
                          viewHolder._player?.addListener(playbackStateListener(viewHolder))
                          viewHolder._player?.play()
                          currentPlayingViewHolder?.stop()
                          currentPlayingViewHolder = viewHolder
                      }
                  }
              }
          }
      }
  }

recyclerView.findViewHolderForAdapterPosition(centerPosition)
이런 함수가 있는지 몰라서 좀 애먹었는데.. 말그대로 내가 원하는 포지션의
viewHolder Item를 찾아주는 고마운? 메서드 였다.
이걸 이용해서 새롭게 중앙에 위치한 viewHolder를 재생 시켰고,
이전에 중앙에 위치했던 currentPlayingViewHolder는 stop을 시켰다.
그리고 마지막에 currentPlayingViewHolder를 수정해주었다.

3. ViewModel 로직

private val _feedsPrevList: MutableLiveData<List<FeedListPrevModel>> = MutableLiveData()
  var feedsPrevList: LiveData<List<FeedListPrevModel>> = _feedsPrevList

        fun getFeedList(ctype: String) {
//        if(!checkNetworkState())
//            return
          if (loading) {
              showToast("로딩중")
              setRefreshDataLoading(false)
              return
          }
          setDataLoading(true)
          feedRepository.getFeedList(ctype).subscribe {
              setDataLoading(false)
              when (it) {
                  is Result.Success -> {
                      _feedsPrevList.value = it.data!!
                  }
                  is Result.Loading -> {}
                  is Result.DataError -> {
                      showToast("피드를 불러올 수 없습니다\n${it.error?.message}")
                  }
              }
          }.apply { addDisposable(this) }
      }

위는 뷰모델의 피드 리스트를 얻어노는 메서드이다. 평범하다. 코루틴+ 플로우 공부해 놓은게 있는데 언제 적용해보지..?

4. Repository 로직

fun getFeedList(ctype: String): Observable<Result<ArrayList<FeedListPrevModel>>> {
       if (feedListGetApi == null)
           return Observable.just(Result.DataError(DataException.NoneApi()))
       val apiType: String
       return feedListGetApi.run {
           this!!.getFeedList(
               FeedListReqModel(
                   ctype
               ).also { apiType = it.type }
           ).subscribeOn(Schedulers.computation())
               .doOnNext {
                   doOnNextJob(it, ApiAddressConstant.Feed.FeedPrev, apiType)
               }
               .map {
                   when {
                       !it.checkApiSuccess() -> Result.DataError(DataException.SeverFail(it.message))
                       it.body == null -> Result.DataError(DataException.RequireDataNull(it.message))
                       else -> Result.Success(it.body!!)
                   }
               }
               .onErrorReturn {
                   onErrorReturnJob(it, ApiAddressConstant.Feed.FeedPrev, apiType)
                   Result.DataError(DataException.ServerCrash())
               }
               .toObservable()
               .observeOn(AndroidSchedulers.mainThread())
       }

   }

위는 repository 에서 rxjava를 활용하여 API에 대한 처리하는 코드이다.
서버 자체에 문제가 있는지, 인터넷 연결 등 서버에는 문제가 없지만
다른 에러인지(서버에서 알려줌), 호출에 성공하였는지를 처리하였다.

이렇게 구현하니 아직 영상이 많지 않아서 그런지
전체적으로 만족하는? 성능을 보여주었다.
여기까지가 최근 리팩토링 한 부분이고 비디오를 가져온 부분에 있어서
확실히 더 쾌적해진 느낌이다.
특히 스크롤 반응 및 영상 재생과 썸네일 전환이 빨라졌다!
만약 또 버그가 생기거나 개선점을 찾는다면.. 수정해보도록 하겠다.

0개의 댓글