서버에서 받은 video url을 리싸이클러뷰에 렌더링을 하는 과정이다.
video를 재생시키는 방법은 Exoplayer 라이브러리를 채택했으며 Android 플랫폼을 위한 오픈 소스, 어플리케이션 레벨의 미디어 플레이어이다.
// 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를 사용하였다.
<?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를 같이 넣어주었다.
아이템은 한 화면에 한 아이템으로 꽉 채울 만큼의 크기를 지정해주었다.
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를 만들었지만 영상 -> 이미지로 아이템이 바뀌고 다시 이전 이미지로 돌아올 경우 까만화면만 보이고 영상이 다시 재생되지 않는 이슈가 있었다.
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하는 식으로 구현했다.
이렇게 구현했을 때 불필요한 영상까지 초기화하고 해제하는 식의 리소스 낭비가 되겠지만 간단한 프로젝트의 경우 임기응변용으로 사용하면 좋을 것 같다.